diff --git a/PLAN.md b/PLAN.md index 25110cf7..22f691dd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -389,40 +389,91 @@ pub struct Edition<'a> { subkind: &'a str, entity: [u8; 32], content: &'a str, pub struct ControlWriter { pub author: PublicKey, pub read: GroupKey, pub signer: GroupKey } impl ControlWriter { pub fn publish(&self, keys: &Keys, edition: Edition<'_>, at_secs: u64) -> Result<(Event, EntityHead)>; - pub fn set_community_metadata(&self, keys, community_id, metadata, head, at_secs) -> Result<(Event, EntityHead)>; - pub fn set_channel_metadata(&self, keys, channel, metadata, head, at_secs) -> Result<(Event, EntityHead)>; + pub fn set_community_metadata(&self, keys, community_id, metadata, head, + citation: Option, at_secs) -> Result<(Event, EntityHead)>; + pub fn set_channel_metadata(&self, keys, channel, metadata, head, citation, at_secs) -> Result<(Event, EntityHead)>; + pub fn set_role(&self, keys, role: &Role, head, citation, at_secs) -> Result<(Event, EntityHead)>; + pub fn set_grant(&self, keys, community_id, grant: &Grant, head, citation, at_secs) -> Result<(Event, EntityHead)>; + pub fn set_banlist(&self, keys, community_id, banned: &BTreeSet, head, citation, at_secs) + -> Result<(Event, EntityHead)>; } ``` -`keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. Roles, grants and banlists ride the same `publish`, and their wrappers land with the moderation API (M5). A remote signer (NIP-46) is not yet plumbed — `publish` takes `&Keys`, not a `NostrSigner`. +`keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. **Every wrapper takes the citation** (M5): a delegated admin edits metadata, roles, grants and the banlist only under a `vac`, so a wrapper that hardcoded `None` would be an owner-only API. Only `publish` is usable without one. A remote signer (NIP-46) is not yet plumbed — every entry point takes `&Keys`, not a `NostrSigner`. Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners. The store applies only the public declaration and the deletion: the public-to-private flip is ignored until the convert flow (key mint plus cursor rebase) lands, and a channel this client holds no key for is not added at all — it arrives with the invite that carries the key. -### 8.3 Guestbook and member list (`guestbook.rs`) +### 8.3 Guestbook and member list (`guestbook.rs`) — implemented in M5 ```rust +pub const KIND_JOIN_LEAVE: u16 = 3306; +pub const KIND_KICK: u16 = 3309; +pub const KIND_SNAPSHOT: u16 = 3312; +pub const MAX_SNAPSHOT_CHUNK: usize = 400; +pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000; + pub enum GuestbookEntry { Join { member: PublicKey, at_ms: u64, invited_by: Option<(String, String)> }, Leave { member: PublicKey, at_ms: u64 }, Kick { actor: PublicKey, target: PublicKey, at_ms: u64, citation: Option }, Snapshot { refounder: PublicKey, members: Vec, snapshot_id: [u8; 32], chunk: (u32, u32), at_ms: u64 }, } +pub struct GuestbookRumor { pub id: EventId, pub author: PublicKey, pub kind: Kind, pub at_ms: u64, + pub entry: GuestbookEntry } +pub enum MemberState { + Joined { at_ms: u64, invited_by: Option<(String, String)> }, + Left { at_ms: u64 }, + Kicked { at_ms: u64, actor: PublicKey }, +} -pub fn coalesce(events: &[GuestbookEvent], now_ms: u64, snapshot_authority: Option<&PublicKey>, +pub fn build_join(member: PublicKey, invited_by: Option<(&str, &str)>, at_ms: u64) -> UnsignedEvent; +pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent; +pub fn build_kick(actor, target: &PublicKey, citation: Option<&AuthorityCitation>, at_ms) -> UnsignedEvent; +pub fn build_snapshot_chunks(refounder, members: &[PublicKey], snapshot_id: [u8; 32], at_ms) + -> Vec; +pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) + -> Result<(Event, Keys), GuestbookError>; +pub fn open(wrap: &Event, group: &GroupKey) + -> Result<(OpenedStream, GuestbookRumor), GuestbookError>; + +pub fn coalesce(rumors: &[GuestbookRumor], now_ms: u64, snapshot_authority: Option<&PublicKey>, can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool) -> BTreeMap; pub fn complete_memberlist(coalesced: &BTreeMap, observed: &BTreeMap, // author → newest ms published - banned: &BTreeSet, banned_at: &BTreeMap, - refound: Option<&Refound>) -> BTreeSet; + granted: &BTreeSet, + banned: &BTreeSet, banned_at: &BTreeMap) + -> BTreeSet; ``` -- Entries dated more than an hour ahead of local time are dropped. An `ms` outside `0..999` drops the entry rather than being interpreted. +- The plane is community-wide, and its key already carries the epoch, so a guestbook rumor binds no + `channel`/`epoch` tags and `GuestbookRumor` carries neither. Coalescing spans every held epoch, which + is what lets a snapshot bridge a Refounding. +- Entries dated more than an hour ahead of local time are dropped. That is a coalesce-time check (`now_ms` + is an argument, so it is testable without a clock); the `ms` range is not — `open` inherits + `open_wrap`'s strict resolution, so an `ms` outside `0..999` drops the entry at parse instead. - Coalesce is per npub, one final state each, by millisecond time, ties on the lower inner rumor id. -- A Kick counts only when its signer holds `KICK` and outranks the target. -- A Snapshot counts only from the npub whose Refounding minted the epoch. There is deliberately no owner fallback. -- The member list is `coalesced Joined ∪ observed authors − banlist`, and observation counts **forward only** (an author re-enters on activity newer than their latest Leave/Kick/Ban). A Grant holder with `ms == 0` is present by construction. +- A Join or Leave is self-signed by construction: `member` comes from the rumor's own author, so there is + no wire field that could name somebody else. +- A Kick counts only where `can_kick(actor, target, citation)` admits it — the roster's `KICK` plus a + strictly higher rank plus a resolvable citation, composed by the caller. A Snapshot counts only from the + npub whose Refounding minted the epoch. There is deliberately no owner fallback. +- `invited_by` is an echo of the optional `invite` tag, never authority, so a malformed or repeated tag + costs the label and not the member's own word. +- `build_snapshot_chunks` is the only snapshot builder: 400 members per event, every chunk sharing one id + and one timestamp. A chunk is independently useful, so `coalesce` seeds each chunk's members at that + chunk's own time and never waits for its siblings. +- The member list is `coalesced Joined ∪ observed authors ∪ Grant holders − banlist`. `granted` is the one + addition to the spec's literal formula: a Grant recipient holds keys, so they are a member even with no + Join and no published activity. It counts as an *unjdated* positive, so any dated Leave, Kick or Ban + beats it. Observation counts forward only: an author re-enters on activity newer than their latest + Leave, Kick or Ban. +- `banned_at` is caller-supplied, and an entry missing from it excludes its npub outright — an empty map + therefore means "every ban is terminal", which is the safe reading. The fold does not yet expose the + banlist head's timestamp, so that plumbing belongs with the registry rather than here. +- The earlier sketch's `refound: Option<&Refound>` argument is dropped: nothing mints a Refound until M7, + and a parameter no caller can fill is a guess. It returns with the refounding that produces one. ### 8.4 Chat plane (`chat.rs`) — implemented in M4 @@ -436,7 +487,7 @@ pub enum ChatAction { Message { reply_to: Option, thread_root: Option }, Reaction { target: EventId, emoji: String }, Edit { target: EventId, content: String }, - Delete { target: EventId, target_kind: Option }, + Delete { target: EventId, target_kind: Option, citation: Option }, Typing, Opaque, } @@ -447,7 +498,8 @@ pub fn build_message(author, channel, epoch, content, quote: Option<&ReplyRef>, pub fn build_comment(author, channel, epoch, content, parent: &Target, root: Option<&Target>, at_ms) -> UnsignedEvent; pub fn build_reaction(author, channel, epoch, target: &Target, emoji: &str, at_ms) -> UnsignedEvent; pub fn build_edit(author, channel, epoch, target: EventId, content: &str, at_ms) -> UnsignedEvent; -pub fn build_delete(author, channel, epoch, target: EventId, target_kind: Option, at_ms) -> UnsignedEvent; +pub fn build_delete(author, channel, epoch, target: EventId, target_kind: Option, + citation: Option<&AuthorityCitation>, at_ms) -> UnsignedEvent; pub fn build_typing(author, channel, epoch, at_ms) -> UnsignedEvent; pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, ephemeral: bool) @@ -455,7 +507,9 @@ pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epheme pub fn open(wrap: &Event, group: &GroupKey, channel: &ChannelId, epoch: Epoch) -> Result<(OpenedStream, ChatRumor), ChatError>; pub fn plane_keys(held: &[(Epoch, [u8; 32])], channel: &ChannelId) -> Result>; -pub fn fold(rumors: &[ChatRumor]) -> Vec; +pub fn fold(rumors: &[ChatRumor], + can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool) + -> Vec; pub struct ChatMessage { pub id: EventId, @@ -480,10 +534,12 @@ pub struct ChatMessage { newest first; mutations replay ascending on `(at_ms, Reverse(id))` so the last one applied wins — the highest `at_ms` and, between equal ones, the lower id. A deletion is terminal: a later edit never revives it. -- **M4 honors a delete only from the message's own author.** A moderator delete (a `vac` - citation under `MANAGE_MESSAGES`) needs the roster, so the builder's `citation`, the - fold's `can_delete` predicate and its tests land with M5. Failing closed here loses a - moderator's reach, never a member's authorship. +- **Since M5 a delete is honored from the message's own author unconditionally, or from anyone + `can_delete` admits.** That predicate is `(actor, citation, target_author)`, composed by the caller + from the roster: a resolvable `vac` citation, `MANAGE_MESSAGES`, and a strictly higher rank than the + author — the delete is the one Chat-plane authority action, which is why the builder takes a citation + and the fold takes a gate. A self-delete never consults the predicate, matching CORD-02 §9's carve-out + that a member's erasure of their own words survives even Dissolution. - `kind 15` is coop's own file-message convention, outside the CORD registry — it is accepted on the read side so a second coop device's files are not dropped, and `send_file` lands with the registry. @@ -543,7 +599,7 @@ pub fn compact(fold, epoch, new_control_root, ...) -> Vec; // re-wrap h Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at `dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the community is sealed read-only: subscriptions halt, nothing new is honored, existing history stays readable, and a member's delete of their own message is still honored. -## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3 +## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3, banlist in M5 Three layers, no new storage engine: @@ -587,11 +643,12 @@ pub struct CommunityState { pub channels: Vec, // id, name, private, epoch pub relays: Vec, pub heads: Vec, // entity, version, self_hash, inner id + pub banned: BTreeSet, // the held banlist, fed back into the next fold pub added_at_ms: u64, } ``` -Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), and `guestbook`/`observed`/`banned`/`dissolved` (need the guestbook, M5). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key. +Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), `dissolved` (needs the tombstone, M7), and `observed`/`guestbook` (need the guestbook ingest of §10). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key. `banned` is a `BTreeSet` — serde has no such problem with a `Vec`-like sequence. M3 added the two bridges between this document and the fold: @@ -602,7 +659,7 @@ impl CommunityState { } ``` -`apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit. `banned` is not yet persisted here: `fold_control` takes the held list as an argument and returns the folded one, and the field lands with the moderation API (M5) that first writes it. +`apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit, and **assigns `banned` wholesale** (M5): the fold already retains a withheld list, so its output is the authority and a caller must not merge it by hand. `floors()` and `banned` are the two inputs the next `fold_control` call needs, which makes the state document a fold cache rather than a second source of truth. Writes are debounced (a fold head changes on every edition); reads load once at init. @@ -756,8 +813,9 @@ Each of these has burned a real implementation, or is a documented cross-client - Enforce the NIP-44 65 535-byte cap at every layer before publishing, and the 5-relay / 256-channel / 50-membership / 100-roles / 64-roles-per-member / 500-banlist / 25-pin caps at their ingest and write points. - Lowercase hex only; x-only pubkeys only; no version tag anywhere. - **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 honored only from the message's own author. -- Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, the guestbook's future-clock and Snapshot rules, and the write-side counterparts. +- **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. +- 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 @@ -768,7 +826,7 @@ Each of these has burned a real implementation, or is a documented cross-client | M2 | `edition.rs` + `control.rs` genesis + `store.rs` state document | ✅ `cargo test -p concord` (7 tests): `edition_hash` reproduces the cross-client vector `2daf42e6…`, and a community minted by one holder has both genesis wraps open for a second holder holding only the invite keys, folding to version 1 | | 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 | join/leave/kick/ban converge to the same memberlist on both clients; every authority rule from §8.1 has a negative test | +| 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 | | 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 | @@ -783,9 +841,13 @@ M3 closed at 14 tests with no dependency change at all, and `Cargo.lock` untouch M4 closed at 19 tests, again with no dependency change and `Cargo.lock` untouched — relay paging is `Client::fetch_events` over the same `NostrDatabase` the cache already used, so nothing new was needed. New: `src/chat.rs` (the whole channel plane) and, in `src/store.rs`, `backfill` plus the pure `advance` page step it is built from, which is what the paging test drives instead of a socket. `edition::canonical_decimal` became `pub(crate)` so the chat tag grammar shares one decimal check. -What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); the **persisted banlist** and `CommunityState.banned` (M5, with the moderation API that writes it); the **role/grant/banlist write wrappers** (M5 — `ControlWriter::publish` already carries them, only the convenience surface is pending); and the **NIP-46 remote signer**, since `publish` takes `&Keys` rather than a `NostrSigner`. +What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); and the **NIP-46 remote signer**, since every writer takes `&Keys` rather than a `NostrSigner`. Its persisted banlist and its role/grant/banlist write wrappers both landed in M5. -What M4 still defers, and to what: **moderator deletes and the `can_delete` predicate** (M5 — M4 honors a delete only from the message's own author, so a moderator's reach is missing rather than forged); **`media`/`mentions`** on `ChatMessage` and **`send_file`** (the registry/UI milestone — the first needs a gpui type and the second needs the blob-upload path); and **the timer's policy** under the `expiration` tag that `seal_rumor` already mirrors (M8). +What M4 still defers, and to what: **`media`/`mentions`** on `ChatMessage` and **`send_file`** (the registry/UI milestone — the first needs a gpui type and the second needs the blob-upload path); and **the timer's policy** under the `expiration` tag that `seal_rumor` already mirrors (M8). Its moderator delete landed in M5. + +M5 closed at 23 tests, again with no dependency change and `Cargo.lock` untouched. New: `src/guestbook.rs` (the whole membership plane — three rumor codecs, the coalesce, the memberlist), a `vac` citation on a kind-5 delete plus the fold's `can_delete` gate, `ControlWriter::set_role`/`set_grant`/`set_banlist`, and `CommunityState.banned`. Two shared helpers moved into `edition.rs` — `citation_tag` and `citation_from` — so the control, chat and guestbook grammars parse one `vac`. Every metadata/role/grant/banlist wrapper now takes its citation, which is a fix rather than an addition: M3's wrappers hardcoded `None` and so were owner-only, which the delegated-metadata test had been working around with `publish`. + +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. **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. diff --git a/crates/concord/src/chat.rs b/crates/concord/src/chat.rs index 067bcccf..0d9a8a1d 100644 --- a/crates/concord/src/chat.rs +++ b/crates/concord/src/chat.rs @@ -6,7 +6,9 @@ use anyhow::Result; use nostr_sdk::prelude::*; use crate::derive::channel_group_key; -use crate::edition::canonical_decimal; +use crate::edition::{ + AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag, +}; use crate::stream::{ KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, @@ -96,6 +98,7 @@ pub enum ChatAction { Delete { target: EventId, target_kind: Option, + citation: Option, }, Typing, Opaque, @@ -217,6 +220,7 @@ pub fn build_delete( epoch: Epoch, target: EventId, target_kind: Option, + citation: Option<&AuthorityCitation>, at_ms: u64, ) -> UnsignedEvent { let mut tags = channel_binding_tags(channel, epoch); @@ -226,6 +230,10 @@ pub fn build_delete( tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()])); } + if let Some(citation) = citation { + tags.push(citation_tag(citation)); + } + build_rumor_ms(KIND_DELETE, author, "", tags, at_ms) } @@ -317,10 +325,10 @@ pub fn plane_keys( .collect() } -/// Folds the chat plane into timeline rows, newest first. A delete is honored -/// only from the message's own author, and a deletion is terminal: an edit or a -/// reaction arriving later never revives it. -pub fn fold(rumors: &[ChatRumor]) -> Vec { +pub fn fold( + rumors: &[ChatRumor], + can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool, +) -> Vec { let mut order: Vec = (0..rumors.len()).collect(); order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id)); @@ -356,8 +364,6 @@ pub fn fold(rumors: &[ChatRumor]) -> Vec { }); } - // Mutations replay so the last one applied is the winner: the highest - // `at_ms` and, between equal ones, the lower inner rumor id. let mut mutations: Vec = (0..rumors.len()).collect(); mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id))); @@ -378,12 +384,16 @@ pub fn fold(rumors: &[ChatRumor]) -> Vec { message.content = content.clone(); message.edited_at = Some(rumor.at_ms); } - ChatAction::Delete { target, .. } => { + ChatAction::Delete { + target, citation, .. + } => { let Some(&slot) = slot.get(target) else { continue; }; - if messages[slot].author == rumor.author { + let author = messages[slot].author; + + if author == rumor.author || can_delete(&rumor.author, citation.as_ref(), &author) { messages[slot].deleted = true; } } @@ -454,6 +464,7 @@ fn action_of(rumor: &UnsignedEvent) -> Result { KIND_DELETE => Ok(ChatAction::Delete { target: required_id(rumor, TAG_TARGET)?, target_kind: optional_kind(rumor, TAG_TARGET_KIND)?, + citation: optional_citation(rumor)?, }), KIND_TYPING => Ok(ChatAction::Typing), KIND_WEBXDC => Ok(ChatAction::Opaque), @@ -469,8 +480,7 @@ fn optional_reply( return Ok(None); }; - // NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the - // referenced author at index 3, which is a SHOULD, so absent reads as unknown. + // NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the referenced author at index 3. let author = match fields.get(3).map(String::as_str) { Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?), _ => None, @@ -500,6 +510,16 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result Result, ChatError> { + let Some(fields) = tag(rumor, TAG_CITATION)? else { + return Ok(None); + }; + + citation_from(fields) + .map(Some) + .ok_or(ChatError::BadTag(TAG_CITATION)) +} + fn expiration_of(rumor: &UnsignedEvent) -> Result, ChatError> { let Some(fields) = tag(rumor, TAG_EXPIRATION)? else { return Ok(None); @@ -646,6 +666,7 @@ mod tests { Epoch(0), id, Some(KIND_MESSAGE), + None, AT + 3_000, ), &group, @@ -654,7 +675,7 @@ mod tests { ), ]; - let folded = fold(&rumors); + let folded = fold(&rumors, |_, _, _| false); assert_eq!(folded.len(), 1); assert_eq!(folded[0].id, id); @@ -698,6 +719,7 @@ mod tests { Epoch(0), id, Some(KIND_MESSAGE), + None, AT + 2_000, ), &group, @@ -706,7 +728,7 @@ mod tests { ), ]; - let folded = fold(&rumors); + let folded = fold(&rumors, |_, _, _| false); assert_eq!(folded.len(), 1); assert_eq!(folded[0].content, "hello"); @@ -851,4 +873,75 @@ mod tests { Err(ChatError::DuplicateTag(TAG_TARGET)) )); } + + #[test] + fn a_moderator_delete_needs_the_roster_and_a_citation() { + let alice = Keys::generate(); + let moderator = Keys::generate(); + let peer = Keys::generate(); + let group = group(); + + let message = read( + &build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT), + &group, + &alice, + Epoch(0), + ); + let id = message.id; + let citation = AuthorityCitation { + entity: [0x33; 32], + version: 1, + hash: [0x44; 32], + }; + + let delete = |author: &Keys, citation: Option<&AuthorityCitation>| { + read( + &build_delete( + author.public_key(), + &channel(), + Epoch(0), + id, + Some(KIND_MESSAGE), + citation, + AT + 1_000, + ), + &group, + author, + Epoch(0), + ) + }; + + let can_delete = + |actor: &PublicKey, citation: Option<&AuthorityCitation>, author: &PublicKey| { + actor != author && citation.is_some() && actor == &moderator.public_key() + }; + + let cited = vec![message.clone(), delete(&moderator, Some(&citation))]; + assert!(matches!( + &cited[1].action, + ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation + )); + assert!( + fold(&cited, can_delete)[0].deleted, + "a cited moderator delete lands" + ); + + let uncited = vec![message.clone(), delete(&moderator, None)]; + assert!( + !fold(&uncited, can_delete)[0].deleted, + "an uncited delete names no rank" + ); + + let peer_delete = vec![message.clone(), delete(&peer, Some(&citation))]; + assert!( + !fold(&peer_delete, can_delete)[0].deleted, + "a peer's delete is not authority" + ); + + let own = vec![message.clone(), delete(&alice, None)]; + assert!( + fold(&own, |_, _, _| false)[0].deleted, + "a self-delete never consults the predicate" + ); + } } diff --git a/crates/concord/src/control.rs b/crates/concord/src/control.rs index 30b56e53..2a6f900d 100644 --- a/crates/concord/src/control.rs +++ b/crates/concord/src/control.rs @@ -5,14 +5,15 @@ use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent}; use serde::{Deserialize, Serialize}; use crate::derive::{ - community_id_of, control_group_key, control_signer_group_key, verify_community_id, + banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator, + verify_community_id, }; use crate::edition::{ AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition, build_edition, fold_head, parse_edition, vsk, }; use crate::roles::{ - AuthorityEdition, CommunityRoles, Permissions, Roster, citation_ok, fold_roster, + AuthorityEdition, CommunityRoles, Grant, Permissions, Role, Roster, citation_ok, fold_roster, }; use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with}; use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32}; @@ -220,6 +221,7 @@ impl ControlWriter { community_id: &CommunityId, metadata: &CommunityMetadata, head: Option<&EntityHead>, + citation: Option, at_secs: u64, ) -> Result<(Event, EntityHead)> { let content = encode_metadata(metadata)?; @@ -231,7 +233,7 @@ impl ControlWriter { entity: *community_id.as_bytes(), content: &content, head, - citation: None, + citation, }, at_secs, ) @@ -243,6 +245,7 @@ impl ControlWriter { channel: &ChannelId, metadata: &ChannelMetadata, head: Option<&EntityHead>, + citation: Option, at_secs: u64, ) -> Result<(Event, EntityHead)> { let content = serde_json::to_string(metadata)?; @@ -254,7 +257,79 @@ impl ControlWriter { entity: *channel.as_bytes(), content: &content, head, - citation: None, + citation, + }, + at_secs, + ) + } + + pub fn set_role( + &self, + keys: &Keys, + role: &Role, + head: Option<&EntityHead>, + citation: Option, + at_secs: u64, + ) -> Result<(Event, EntityHead)> { + let content = role.to_content()?; + + self.publish( + keys, + Edition { + subkind: vsk::ROLE, + entity: *role.role_id.as_bytes(), + content: &content, + head, + citation, + }, + at_secs, + ) + } + + pub fn set_grant( + &self, + keys: &Keys, + community_id: &CommunityId, + grant: &Grant, + head: Option<&EntityHead>, + citation: Option, + at_secs: u64, + ) -> Result<(Event, EntityHead)> { + let content = grant.to_content()?; + + self.publish( + keys, + Edition { + subkind: vsk::GRANT, + entity: grant_locator(community_id, &grant.member.to_bytes()), + content: &content, + head, + citation, + }, + at_secs, + ) + } + + pub fn set_banlist( + &self, + keys: &Keys, + community_id: &CommunityId, + banned: &BTreeSet, + head: Option<&EntityHead>, + citation: Option, + at_secs: u64, + ) -> Result<(Event, EntityHead)> { + let entries: Vec = banned.iter().map(PublicKey::to_hex).collect(); + let content = serde_json::to_string(&entries)?; + + self.publish( + keys, + Edition { + subkind: vsk::BANLIST, + entity: banlist_locator(community_id), + content: &content, + head, + citation, }, at_secs, ) @@ -600,6 +675,7 @@ mod tests { ..metadata("coop two") }, Some(community_head), + None, AT + 1, ) .expect("publishes"); @@ -613,6 +689,7 @@ mod tests { ..ChannelMetadata::default() }, Some(channel_head), + None, AT + 2, ) .expect("publishes"); diff --git a/crates/concord/src/edition.rs b/crates/concord/src/edition.rs index 83236b0d..cb5fed73 100644 --- a/crates/concord/src/edition.rs +++ b/crates/concord/src/edition.rs @@ -31,7 +31,7 @@ const TAG_SUBKIND: &str = "vsk"; const TAG_ENTITY: &str = "eid"; const TAG_VERSION: &str = "ev"; const TAG_PREV: &str = "ep"; -const TAG_CITATION: &str = "vac"; +pub const TAG_CITATION: &str = "vac"; #[derive(Debug)] pub enum EditionError { @@ -126,6 +126,29 @@ pub fn edition_hash( Sha256::digest(signing_bytes(entity, version, prev, content)).into() } +pub fn citation_tag(citation: &AuthorityCitation) -> Tag { + Tag::custom( + TAG_CITATION, + [ + HEXLOWER.encode(&citation.entity), + citation.version.to_string(), + HEXLOWER.encode(&citation.hash), + ], + ) +} + +pub fn citation_from(fields: &[String]) -> Option { + if fields.len() != 4 { + return None; + } + + Some(AuthorityCitation { + entity: hex32(&fields[1], TAG_CITATION).ok()?, + version: canonical_decimal(&fields[2])?, + hash: hex32(&fields[3], TAG_CITATION).ok()?, + }) +} + pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent { let mut tags = vec![ Tag::custom(TAG_SUBKIND, [fields.subkind]), @@ -138,14 +161,7 @@ pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent { } if let Some(citation) = fields.citation { - tags.push(Tag::custom( - TAG_CITATION, - [ - HEXLOWER.encode(&citation.entity), - citation.version.to_string(), - HEXLOWER.encode(&citation.hash), - ], - )); + tags.push(citation_tag(&citation)); } build_rumor_secs( @@ -187,12 +203,7 @@ pub fn parse_edition(rumor: &UnsignedEvent) -> Result Some(AuthorityCitation { - entity: hex32(&fields[1], TAG_CITATION)?, - version: canonical_decimal(&fields[2]).ok_or(EditionError::BadField(TAG_CITATION))?, - hash: hex32(&fields[3], TAG_CITATION)?, - }), - Some(_) => return Err(EditionError::BadField(TAG_CITATION)), + Some(fields) => Some(citation_from(fields).ok_or(EditionError::BadField(TAG_CITATION))?), None => None, }; diff --git a/crates/concord/src/guestbook.rs b/crates/concord/src/guestbook.rs new file mode 100644 index 00000000..95a7db88 --- /dev/null +++ b/crates/concord/src/guestbook.rs @@ -0,0 +1,890 @@ +use std::cmp::Reverse; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use anyhow::Result; +use data_encoding::HEXLOWER; +use nostr_sdk::prelude::*; + +use crate::edition::{ + AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag, +}; +use crate::stream::{ + KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap, + wrap_seal, +}; +use crate::{GroupKey, decode_hex_32}; + +pub const KIND_JOIN_LEAVE: u16 = 3306; +pub const KIND_KICK: u16 = 3309; +pub const KIND_SNAPSHOT: u16 = 3312; + +pub const MAX_SNAPSHOT_CHUNK: usize = 400; +pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000; + +const TAG_INVITE: &str = "invite"; +const TAG_TARGET: &str = "p"; +const TAG_SNAP: &str = "snap"; +const TAG_CONTENT: &str = "content"; +const CONTENT_JOIN: &str = "join"; +const CONTENT_LEAVE: &str = "leave"; + +#[derive(Debug)] +pub enum GuestbookError { + Stream(StreamError), + NotEncryptedSealed, + UnknownKind(u16), + MissingTag(&'static str), + DuplicateTag(&'static str), + BadTag(&'static str), +} + +impl fmt::Display for GuestbookError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GuestbookError::Stream(error) => write!(f, "stream: {error}"), + GuestbookError::NotEncryptedSealed => { + write!(f, "guestbook rumor must ride an encrypted seal") + } + GuestbookError::UnknownKind(kind) => { + write!(f, "not a guestbook rumor kind: {kind}") + } + GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"), + GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"), + GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"), + } + } +} + +impl std::error::Error for GuestbookError {} + +impl From for GuestbookError { + fn from(error: StreamError) -> Self { + GuestbookError::Stream(error) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuestbookEntry { + Join { + member: PublicKey, + at_ms: u64, + /// The `(creator, label)` an invite attributed the join to. + invited_by: Option<(String, String)>, + }, + Leave { + member: PublicKey, + at_ms: u64, + }, + Kick { + actor: PublicKey, + target: PublicKey, + at_ms: u64, + citation: Option, + }, + Snapshot { + refounder: PublicKey, + members: Vec, + snapshot_id: [u8; 32], + chunk: (u32, u32), + at_ms: u64, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GuestbookRumor { + pub id: EventId, + pub author: PublicKey, + pub kind: Kind, + pub at_ms: u64, + pub entry: GuestbookEntry, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MemberState { + Joined { + at_ms: u64, + invited_by: Option<(String, String)>, + }, + Left { + at_ms: u64, + }, + Kicked { + at_ms: u64, + actor: PublicKey, + }, +} + +pub fn build_join( + member: PublicKey, + invited_by: Option<(&str, &str)>, + at_ms: u64, +) -> UnsignedEvent { + let mut tags = Vec::new(); + + if let Some((creator, label)) = invited_by { + tags.push(Tag::custom(TAG_INVITE, [creator, label])); + } + + build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_JOIN, tags, at_ms) +} + +pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent { + build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_LEAVE, Vec::new(), at_ms) +} + +pub fn build_kick( + actor: PublicKey, + target: &PublicKey, + citation: Option<&AuthorityCitation>, + at_ms: u64, +) -> UnsignedEvent { + let mut tags = vec![Tag::custom(TAG_TARGET, [target.to_hex()])]; + + if let Some(citation) = citation { + tags.push(citation_tag(citation)); + } + + build_rumor_ms(KIND_KICK, actor, "", tags, at_ms) +} + +pub fn build_snapshot_chunks( + refounder: PublicKey, + members: &[PublicKey], + snapshot_id: [u8; 32], + at_ms: u64, +) -> Vec { + let chunks: Vec<&[PublicKey]> = members.chunks(MAX_SNAPSHOT_CHUNK).collect(); + let total = chunks.len() as u32; + + chunks + .iter() + .enumerate() + .map(|(index, chunk)| { + let hex: Vec = chunk.iter().map(PublicKey::to_hex).collect(); + let content = format!( + "[{}]", + hex.iter() + .map(|member| format!("\"{member}\"")) + .collect::>() + .join(",") + ); + let tags = vec![Tag::custom( + TAG_SNAP, + [ + HEXLOWER.encode(&snapshot_id), + (index as u32 + 1).to_string(), + total.to_string(), + ], + )]; + + build_rumor_ms(KIND_SNAPSHOT, refounder, &content, tags, at_ms) + }) + .collect() +} + +pub fn seal_rumor( + rumor: &UnsignedEvent, + group: &GroupKey, + author: &Keys, +) -> Result<(Event, Keys), GuestbookError> { + let kind = rumor.kind.as_u16(); + + if !is_guestbook_kind(kind) { + return Err(GuestbookError::UnknownKind(kind)); + } + + let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; + + Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?) +} + +pub fn open( + wrap: &Event, + group: &GroupKey, +) -> Result<(OpenedStream, GuestbookRumor), GuestbookError> { + let opened = open_wrap(wrap, group)?; + + if opened.seal_form != SealForm::Encrypted { + return Err(GuestbookError::NotEncryptedSealed); + } + + let entry = entry_of(&opened)?; + let rumor = GuestbookRumor { + id: opened.rumor_id, + author: opened.author, + kind: opened.rumor.kind, + at_ms: opened.at_ms, + entry, + }; + + Ok((opened, rumor)) +} + +pub fn coalesce( + rumors: &[GuestbookRumor], + now_ms: u64, + snapshot_authority: Option<&PublicKey>, + can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool, +) -> BTreeMap { + let mut states: BTreeMap, MemberState)> = BTreeMap::new(); + let horizon = now_ms.saturating_add(MAX_FUTURE_SKEW_MS); + + for rumor in rumors { + if rumor.at_ms > horizon { + continue; + } + + match &rumor.entry { + GuestbookEntry::Join { + member, + at_ms, + invited_by, + } => offer( + &mut states, + *member, + *at_ms, + rumor.id, + MemberState::Joined { + at_ms: *at_ms, + invited_by: invited_by.clone(), + }, + ), + GuestbookEntry::Leave { member, at_ms } => offer( + &mut states, + *member, + *at_ms, + rumor.id, + MemberState::Left { at_ms: *at_ms }, + ), + GuestbookEntry::Kick { + actor, + target, + at_ms, + citation, + } => { + if !can_kick(actor, target, citation.as_ref()) { + continue; + } + + offer( + &mut states, + *target, + *at_ms, + rumor.id, + MemberState::Kicked { + at_ms: *at_ms, + actor: *actor, + }, + ); + } + GuestbookEntry::Snapshot { + refounder, + members, + at_ms, + .. + } => { + if snapshot_authority != Some(refounder) { + continue; + } + + for member in members { + offer( + &mut states, + *member, + *at_ms, + rumor.id, + MemberState::Joined { + at_ms: *at_ms, + invited_by: None, + }, + ); + } + } + } + } + + states + .into_iter() + .map(|(member, (_, _, state))| (member, state)) + .collect() +} + +pub fn complete_memberlist( + coalesced: &BTreeMap, + observed: &BTreeMap, + granted: &BTreeSet, + banned: &BTreeSet, + banned_at: &BTreeMap, +) -> BTreeSet { + let mut candidates: BTreeSet<&PublicKey> = coalesced.keys().collect(); + candidates.extend(observed.keys()); + candidates.extend(granted.iter()); + + let mut members = BTreeSet::new(); + + for member in candidates { + let mut inclusion = observed.get(member).copied(); + + if let Some(state) = coalesced.get(member) { + match state { + MemberState::Joined { at_ms, .. } => { + inclusion = Some(inclusion.map_or(*at_ms, |seen| seen.max(*at_ms))); + } + MemberState::Left { .. } | MemberState::Kicked { .. } => {} + } + } + + if inclusion.is_none() && granted.contains(member) { + inclusion = Some(0); + } + + let mut exclusion = match coalesced.get(member) { + Some(MemberState::Left { at_ms }) | Some(MemberState::Kicked { at_ms, .. }) => { + Some(*at_ms) + } + _ => None, + }; + + if banned.contains(member) { + exclusion = Some(match banned_at.get(member) { + Some(at_ms) => exclusion.map_or(*at_ms, |seen| seen.max(*at_ms)), + None => u64::MAX, + }); + } + + if let Some(inclusion) = inclusion + && exclusion.is_none_or(|exclusion| inclusion > exclusion) + { + members.insert(*member); + } + } + + members +} + +fn offer( + states: &mut BTreeMap, MemberState)>, + member: PublicKey, + at_ms: u64, + id: EventId, + state: MemberState, +) { + let candidate = (at_ms, Reverse(id)); + + if let Some(existing) = states.get(&member) + && (existing.0, existing.1) >= candidate + { + return; + } + + states.insert(member, (at_ms, Reverse(id), state)); +} + +fn is_guestbook_kind(kind: u16) -> bool { + matches!(kind, KIND_JOIN_LEAVE | KIND_KICK | KIND_SNAPSHOT) +} + +fn entry_of(opened: &OpenedStream) -> Result { + let rumor = &opened.rumor; + let author = opened.author; + let at_ms = opened.at_ms; + + match rumor.kind.as_u16() { + KIND_JOIN_LEAVE => match rumor.content.as_str() { + CONTENT_JOIN => Ok(GuestbookEntry::Join { + member: author, + at_ms, + invited_by: invite_of(rumor), + }), + CONTENT_LEAVE => Ok(GuestbookEntry::Leave { + member: author, + at_ms, + }), + _ => Err(GuestbookError::BadTag(TAG_CONTENT)), + }, + KIND_KICK => Ok(GuestbookEntry::Kick { + actor: author, + target: tagged_pubkey(rumor, TAG_TARGET)?, + at_ms, + citation: optional_citation(rumor)?, + }), + KIND_SNAPSHOT => { + let (snapshot_id, chunk) = snapshot_of(rumor)?; + let members = members_of(&rumor.content)?; + + Ok(GuestbookEntry::Snapshot { + refounder: author, + members, + snapshot_id, + chunk, + at_ms, + }) + } + other => Err(GuestbookError::UnknownKind(other)), + } +} + +fn invite_of(rumor: &UnsignedEvent) -> Option<(String, String)> { + rumor.tags.iter().find_map(|candidate| { + let fields = candidate.as_slice(); + + (fields.len() >= 3 && fields[0] == TAG_INVITE) + .then(|| (fields[1].clone(), fields[2].clone())) + }) +} + +fn members_of(content: &str) -> Result, GuestbookError> { + let entries: Vec = + serde_json::from_str(content).map_err(|_| GuestbookError::BadTag(TAG_CONTENT))?; + + if entries.len() > MAX_SNAPSHOT_CHUNK { + return Err(GuestbookError::BadTag(TAG_SNAP)); + } + + entries + .iter() + .map(|entry| pubkey(entry, TAG_CONTENT)) + .collect() +} + +fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), GuestbookError> { + let fields = required(rumor, TAG_SNAP)?; + + if fields.len() != 4 { + return Err(GuestbookError::BadTag(TAG_SNAP)); + } + + let snapshot_id = decode_hex_32(&fields[1]).map_err(|_| GuestbookError::BadTag(TAG_SNAP))?; + let index = decimal(&fields[2])?; + let total = decimal(&fields[3])?; + + if index == 0 || index > total { + return Err(GuestbookError::BadTag(TAG_SNAP)); + } + + Ok((snapshot_id, (index, total))) +} + +fn optional_citation(rumor: &UnsignedEvent) -> Result, GuestbookError> { + let Some(fields) = tag(rumor, TAG_CITATION)? else { + return Ok(None); + }; + + citation_from(fields) + .map(Some) + .ok_or(GuestbookError::BadTag(TAG_CITATION)) +} + +fn decimal(raw: &str) -> Result { + canonical_decimal(raw) + .and_then(|value| u32::try_from(value).ok()) + .ok_or(GuestbookError::BadTag(TAG_SNAP)) +} + +fn required<'a>( + rumor: &'a UnsignedEvent, + name: &'static str, +) -> Result<&'a [String], GuestbookError> { + tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name)) +} + +fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result { + pubkey(value(required(rumor, name)?, name)?, name) +} + +fn tag<'a>( + rumor: &'a UnsignedEvent, + name: &'static str, +) -> Result, GuestbookError> { + let mut found: Option<&[String]> = None; + + for candidate in rumor.tags.iter() { + let fields = candidate.as_slice(); + + if fields.first().map(String::as_str) != Some(name) { + continue; + } + + if found.is_some() { + return Err(GuestbookError::DuplicateTag(name)); + } + + found = Some(fields); + } + + Ok(found) +} + +fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> { + fields + .get(1) + .map(String::as_str) + .ok_or(GuestbookError::BadTag(name)) +} + +fn pubkey(hex: &str, name: &'static str) -> Result { + let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?; + + PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive::guestbook_group_key; + use crate::stream::build_rumor_secs; + use crate::{CommunityId, Epoch}; + + const ROOT: [u8; 32] = [0x5au8; 32]; + const AT: u64 = 1_700_000_000_000; + + fn community() -> CommunityId { + CommunityId::from_bytes([0x11u8; 32]) + } + + fn group() -> GroupKey { + guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives") + } + + fn citation() -> AuthorityCitation { + AuthorityCitation { + entity: [0x33u8; 32], + version: 1, + hash: [0x44u8; 32], + } + } + + fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor { + let wrap = seal_rumor(rumor, &group(), author).expect("seals").0; + + open(&wrap, &group()).expect("opens").1 + } + + #[test] + fn join_leave_kick_and_snapshot_converge_to_one_memberlist() { + let alice = Keys::generate(); + let bob = Keys::generate(); + let carol = Keys::generate(); + let dave = Keys::generate(); + let frank = Keys::generate(); + let grace = Keys::generate(); + let owner = Keys::generate(); + + let survivors: Vec = (0..401).map(|_| Keys::generate().public_key()).collect(); + + let mut rumors = vec![ + publish( + &build_join( + alice.public_key(), + Some((&"ab".repeat(32), "Reddit")), + AT + 1_000, + ), + &alice, + ), + publish(&build_join(bob.public_key(), None, AT + 2_000), &bob), + publish(&build_leave(bob.public_key(), AT + 3_000), &bob), + publish(&build_join(dave.public_key(), None, AT + 4_000), &dave), + publish( + &build_kick( + carol.public_key(), + &dave.public_key(), + Some(&citation()), + AT + 5_000, + ), + &carol, + ), + publish(&build_join(frank.public_key(), None, AT + 7_000), &frank), + ]; + + let snapshot_id = "77".repeat(32); + let chunks = + build_snapshot_chunks(carol.public_key(), &survivors, [0x77u8; 32], AT + 6_000); + assert_eq!(chunks.len(), 2, "401 survivors chunk into two events"); + for (index, chunk) in chunks.iter().enumerate() { + assert!(chunk.tags.iter().any(|tag| tag.as_slice() + == [ + TAG_SNAP, + snapshot_id.as_str(), + &(index + 1).to_string(), + "2" + ])); + rumors.push(publish(chunk, &carol)); + } + + let can_kick = + |actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| { + citation.is_some() && actor == &carol.public_key() && target != &owner.public_key() + }; + + let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick); + + assert_eq!( + states.get(&alice.public_key()), + Some(&MemberState::Joined { + at_ms: AT + 1_000, + invited_by: Some(("ab".repeat(32), "Reddit".to_owned())), + }) + ); + assert_eq!( + states.get(&bob.public_key()), + Some(&MemberState::Left { at_ms: AT + 3_000 }) + ); + assert_eq!( + states.get(&dave.public_key()), + Some(&MemberState::Kicked { + at_ms: AT + 5_000, + actor: carol.public_key(), + }) + ); + assert!( + survivors + .iter() + .all(|member| matches!(states.get(member), Some(MemberState::Joined { .. }))), + "every chunk seeds its own members" + ); + + let reversed: Vec = rumors.iter().rev().cloned().collect(); + assert_eq!( + coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick), + states, + "arrival order cannot change the fold" + ); + + let observed = BTreeMap::from([ + (bob.public_key(), AT + 9_000), + (carol.public_key(), AT + 5_000), + ]); + let granted = BTreeSet::from([grace.public_key()]); + let banned = BTreeSet::from([frank.public_key()]); + let banned_at = BTreeMap::from([(frank.public_key(), AT + 8_000)]); + + let members = complete_memberlist(&states, &observed, &granted, &banned, &banned_at); + + let mut expected = BTreeSet::from([ + alice.public_key(), + bob.public_key(), + carol.public_key(), + grace.public_key(), + ]); + expected.extend(survivors.iter().copied()); + + assert_eq!(members, expected); + assert!( + !members.contains(&dave.public_key()), + "a kicked member is out" + ); + assert!( + !members.contains(&frank.public_key()), + "a ban wins over a later join" + ); + } + + #[test] + fn a_kick_or_snapshot_without_authority_is_dropped() { + let moderator = Keys::generate(); + let outsider = Keys::generate(); + let owner = Keys::generate(); + let kicked = Keys::generate(); + let uncited = Keys::generate(); + let unranked = Keys::generate(); + let refounder = Keys::generate(); + let impostor = Keys::generate(); + let seeded = Keys::generate(); + let smuggled = Keys::generate(); + + let can_kick = |actor: &PublicKey, + target: &PublicKey, + citation: Option<&AuthorityCitation>| { + citation.is_some() && actor == &moderator.public_key() && target != &owner.public_key() + }; + + let rumors = vec![ + publish( + &build_kick( + moderator.public_key(), + &kicked.public_key(), + Some(&citation()), + AT, + ), + &moderator, + ), + publish( + &build_kick(moderator.public_key(), &uncited.public_key(), None, AT), + &moderator, + ), + publish( + &build_kick( + outsider.public_key(), + &unranked.public_key(), + Some(&citation()), + AT, + ), + &outsider, + ), + publish( + &build_kick( + moderator.public_key(), + &owner.public_key(), + Some(&citation()), + AT, + ), + &moderator, + ), + ]; + + let states = coalesce(&rumors, AT + 1_000, None, can_kick); + + assert_eq!( + states.get(&kicked.public_key()), + Some(&MemberState::Kicked { + at_ms: AT, + actor: moderator.public_key(), + }) + ); + assert!( + !states.contains_key(&uncited.public_key()), + "a kick cites the Grant it acts under" + ); + assert!( + !states.contains_key(&unranked.public_key()), + "a kick needs KICK" + ); + assert!( + !states.contains_key(&owner.public_key()), + "nobody kicks the owner" + ); + + let by_refounder = build_snapshot_chunks( + refounder.public_key(), + &[seeded.public_key()], + [0x77u8; 32], + AT, + ) + .remove(0); + let by_impostor = build_snapshot_chunks( + impostor.public_key(), + &[smuggled.public_key()], + [0x88u8; 32], + AT, + ) + .remove(0); + + for authority in [None, Some(refounder.public_key())] { + let states = coalesce( + &[ + publish(&by_refounder, &refounder), + publish(&by_impostor, &impostor), + ], + AT + 1_000, + authority.as_ref(), + |_, _, _| true, + ); + + assert_eq!( + states.contains_key(&seeded.public_key()), + authority.is_some(), + "only the epoch's refounder seeds, and there is no owner fallback" + ); + assert!( + !states.contains_key(&smuggled.public_key()), + "a foreign snapshot never seeds" + ); + } + } + + #[test] + fn a_future_entry_a_bad_ms_and_a_malformed_snapshot_are_dropped() { + let member = Keys::generate(); + let moderator = Keys::generate(); + let target = Keys::generate(); + + let future = publish( + &build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS + 1), + &member, + ); + let horizon = publish( + &build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS), + &member, + ); + assert!( + coalesce(&[future], AT, None, |_, _, _| true).is_empty(), + "an entry more than an hour ahead is dropped" + ); + assert_eq!( + coalesce(&[horizon], AT, None, |_, _, _| true).len(), + 1, + "the horizon itself is skew, not forgery" + ); + + let bad_ms = build_rumor_secs( + KIND_JOIN_LEAVE, + member.public_key(), + CONTENT_JOIN, + vec![Tag::custom("ms", ["1000"])], + AT / 1000, + ); + assert!(matches!( + open( + &seal_rumor(&bad_ms, &group(), &member).expect("seals").0, + &group() + ), + Err(GuestbookError::Stream(StreamError::BadMs)) + )); + + let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT); + assert!(matches!( + open( + &seal_rumor(&bad_verb, &group(), &member).expect("seals").0, + &group() + ), + Err(GuestbookError::BadTag(TAG_CONTENT)) + )); + + let ambiguous = build_rumor_ms( + KIND_KICK, + moderator.public_key(), + "", + vec![ + Tag::custom(TAG_TARGET, [target.public_key().to_hex()]), + citation_tag(&citation()), + citation_tag(&citation()), + ], + AT, + ); + assert!(matches!( + open( + &seal_rumor(&ambiguous, &group(), &moderator) + .expect("seals") + .0, + &group() + ), + Err(GuestbookError::DuplicateTag(TAG_CITATION)) + )); + + for fields in [ + vec![snapshot_id(), "0".to_owned(), "2".to_owned()], + vec![snapshot_id(), "3".to_owned(), "2".to_owned()], + vec![snapshot_id(), "1".to_owned()], + ] { + let rumor = build_rumor_ms( + KIND_SNAPSHOT, + moderator.public_key(), + "[]", + vec![Tag::custom(TAG_SNAP, fields)], + AT, + ); + assert!(matches!( + open( + &seal_rumor(&rumor, &group(), &moderator).expect("seals").0, + &group() + ), + Err(GuestbookError::BadTag(TAG_SNAP)) + )); + } + } + + fn snapshot_id() -> String { + "ab".repeat(32) + } +} diff --git a/crates/concord/src/lib.rs b/crates/concord/src/lib.rs index 4f38a0dd..088dadf6 100644 --- a/crates/concord/src/lib.rs +++ b/crates/concord/src/lib.rs @@ -2,6 +2,7 @@ pub mod chat; pub mod control; pub mod derive; pub mod edition; +pub mod guestbook; pub mod roles; pub mod store; pub mod stream; diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index 8d31818e..21c1bc2b 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -118,6 +118,8 @@ pub struct CommunityState { pub relays: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub heads: Vec, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub banned: BTreeSet, pub added_at_ms: u64, } @@ -183,6 +185,7 @@ impl CommunityState { channels, relays, heads, + banned: BTreeSet::new(), added_at_ms, }) } @@ -200,6 +203,7 @@ impl CommunityState { pub fn apply_fold(&mut self, fold: &ControlFold) { self.heads = fold.floors.values().cloned().collect(); + self.banned = fold.banned.clone(); if let Some(community) = &fold.community { self.relays = community