feat: implement concord protocol #49

Merged
reya merged 12 commits from feat/concord into master 2026-09-17 04:30:13 +00:00
12 changed files with 1677 additions and 90 deletions
Showing only changes of commit fd39be0eda - Show all commits
Generated
+2
View File
@@ -1307,8 +1307,10 @@ name = "concord"
version = "1.0.2"
dependencies = [
"anyhow",
"chacha20 0.9.1",
"data-encoding",
"hkdf",
"hmac 0.12.1",
"nostr",
"nostr-memory",
"nostr-sdk",
+3
View File
@@ -32,6 +32,9 @@ aes-gcm = "0.10"
sha2 = "0.10"
data-encoding = "2"
hkdf = "0.12"
# Pinned to the instances `nostr` already builds: the NIP-44 message-key disclosure
chacha20 = "0.9"
hmac = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng" ] }
+135 -16
View File
@@ -16,7 +16,7 @@ Scope of this plan: **backend + public Rust API**. No views, no widgets, no copy
- Any UI work.
- CORD-07 audio/video. Reserve `23313`, the `concord/voice-*` labels and the `voice` metadata flag so nothing else claims them, and implement nothing.
- Pins (CORD-04 §7) ship in the last milestone; the design accounts for `vsk 11` early so the fold is not retrofitted.
- Pins (CORD-04 §7) and disappearing messages (CORD-08) land in M8, the last milestone; the design accounted for `vsk 11` and `PIN_MESSAGES` from M3 so neither fold is retrofitted.
- Cross-client interop testing (Vector/Armada/Grimoire). Tracked as follow-up work, not blocking.
## 2. Sources of truth
@@ -59,7 +59,7 @@ Verified against the pinned revision (`nostr` 0.45.4 / `nostr-sdk` 0.45.2, git `
**Not needed.** `secp256k1` (use `nostr::SecretKey::from_slice` + `Keys::new`), `base64` (use `data_encoding::BASE64`, already a workspace dep), `bech32` (NIP-19 is in the SDK), any new storage engine (the client's LMDB database is enough), any new HTTP client.
**Dependencies added so far:** `hkdf = "0.12"` at M0 (already in `Cargo.lock` transitively) and `rand = "0.10"` at M1 for the NIP-44 nonce, pinned to the exact instance `nostr` already builds (`default-features = false`, features `std` + `sys_rng`) so `nostr`'s `os-rng` and ours unify on one `rand`/`getrandom`. `sha2` and `data-encoding` were already workspace deps. **Two more at M8:** the Pin List's per-message key disclosure needs `chacha20 = "0.9"` (already in the tree because we enable nostr's `nip44`, which is where `chacha20` comes from) and `hmac = "0.12"` (already in the tree via `hkdf`) — see §14.8. Zero new crates so far, and all of these are direct-dependency lines only.
**Dependencies added so far:** `hkdf = "0.12"` at M0 and `rand = "0.10"` at M1 for the NIP-44 nonce, pinned to the exact instance `nostr` already builds (`default-features = false`, features `std` + `sys_rng`) so `nostr`'s `os-rng` and ours unify on one `rand`/`getrandom`; `sha2` and `data-encoding` were already workspace deps. M8 added the Pin List's two, read off the crate graph rather than chosen: `chacha20 = "0.9"` (the instance nostr's `nip44` builds) and `hmac = "0.12"` (the instance `hkdf` builds) — see §14.8. **No new package has ever been added**, and M8's pair changed `Cargo.lock` only by the two `concord` edges; every addition is a direct-dependency line on something already compiled.
**Why not depend on Vector's crates.** `vector-core` (published, MIT) holds the only other Rust Concord implementation, in `src/community/v2/*`. It is not reusable as a dependency, and the "reuse their crypto" argument does not hold:
@@ -87,6 +87,7 @@ crates/concord/
src/chat.rs CORD-03: channel plane — message/edit/delete/reaction builders + message view
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/pins.rs CORD-04 §7: the Pin List, the NIP-44 key disclosure, the two content forms
src/rekey.rs CORD-06: blob codec, continuity, refounding, compaction, dissolution
src/store.rs local persistence + opened-rumor cache + history queries
```
@@ -360,7 +361,7 @@ One reference limitation we **reproduce and do not fix** (recorded here rather t
### 8.2 Communities, channels, metadata
`CommunityMetadata` carries `name` (≤ 64 bytes), `description` (≤ 10 000 bytes), `relays` (truncated on read and write to 5), `icon` and `banner` as encrypted-blob pointers (`{url, key, nonce, hash}`), and the optional `custom` object. `ChannelMetadata` carries `name`, `private`, optional `voice`, `deleted`, optional `custom`. Every content struct carries `#[serde(flatten)] extra`, so a field one client does not model still round-trips and a rename by an older client cannot wipe another client's `custom` keys.
`CommunityMetadata` carries `name` (≤ 64 bytes), `description` (≤ 10 000 bytes), `relays` (truncated on read and write to 5), `icon` and `banner` as encrypted-blob pointers (`{url, key, nonce, hash}`), the optional `custom` object, and `message_expiration` (CORD-08's timer, in seconds, §8.8). `ChannelMetadata` carries `name`, `private`, optional `voice`, `deleted`, optional `custom`. Every content struct carries `#[serde(flatten)] extra`, so a field one client does not model still round-trips and a rename by an older client cannot wipe another client's `custom` keys.
The Control Plane's whole projection is one call:
@@ -370,11 +371,15 @@ pub struct ControlFold {
pub banned: BTreeSet<PublicKey>,
pub community: Option<CommunityMetadata>,
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
pub registries: BTreeMap<PublicKey, Vec<PublicKey>>, // vsk 8, keyed by creator
pub pins: BTreeMap<[u8; 32], String>, // vsk 11 content, keyed by locator (§8.8)
pub floors: Floors,
pub gapped: bool,
}
pub fn fold_control(owner: &PublicKey, community_id: &CommunityId, editions: &[ParsedEdition],
floors: &Floors, held_bans: &BTreeSet<PublicKey>) -> ControlFold;
impl ControlFold { pub fn is_public(&self) -> bool;
pub fn pin_content(&self, community_id, channel) -> Option<&str>; }
```
- The roster is folded first, and `vsk 0` / `vsk 2` are then judged against it: the head of each entity is the highest edition whose author *currently* holds `MANAGE_METADATA` / `MANAGE_CHANNELS`, is not banned, and either is the owner or cites their own folded Grant. Pre-filtering before the chain fold is what stops a demoted admin's later, higher-version edition from being the head.
@@ -397,6 +402,10 @@ impl ControlWriter {
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<PublicKey>, head, citation, at_secs)
-> Result<(Event, EntityHead)>;
pub fn set_registry(&self, keys, community_id, creator, links: &[PublicKey], head, citation, at_secs)
-> Result<(Event, EntityHead)>;
pub fn set_pin_list(&self, keys, community_id, channel, content: &str, head, citation, at_secs)
-> Result<(Event, EntityHead)>;
}
```
@@ -479,7 +488,8 @@ pub fn complete_memberlist(coalesced: &BTreeMap<PublicKey, MemberState>,
### 8.4 Chat plane (`chat.rs`) — implemented in M4
Kinds (CORD-02 Appendix B): `9` message, `1111` NIP-22 comment, `7` NIP-25 reaction,
`5` NIP-09 delete, `3302` edit, `3310` WebXDC peer signal, `23311` ephemeral typing.
`5` NIP-09 delete, `3302` edit, `1740` timer notice (M8), `3310` WebXDC peer signal,
`23311` ephemeral typing.
```rust
pub struct ChatRumor { id, author, kind, channel, epoch, at_ms, content,
@@ -490,17 +500,19 @@ pub enum ChatAction {
Edit { target: EventId, content: String },
Delete { target: EventId, target_kind: Option<u16>, citation: Option<AuthorityCitation> },
Typing,
TimerNotice { seconds: u64 },
Opaque,
}
pub struct ReplyRef { id: EventId, author: Option<PublicKey> }
pub struct Target { reply: ReplyRef, kind: u16 } // the wire commits the target's kind
pub fn build_message(author, channel, epoch, content, quote: Option<&ReplyRef>, at_ms) -> UnsignedEvent;
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_message(author, channel, epoch, content, quote: Option<&ReplyRef>, at_ms, timer: Option<u64>) -> UnsignedEvent;
pub fn build_comment(author, channel, epoch, content, parent: &Target, root: Option<&Target>, at_ms, timer) -> UnsignedEvent;
pub fn build_reaction(author, channel, epoch, target: &Target, emoji: &str, at_ms, timer) -> UnsignedEvent;
pub fn build_edit(author, channel, epoch, target: EventId, content: &str, at_ms, timer) -> UnsignedEvent;
pub fn build_delete(author, channel, epoch, target: EventId, target_kind: Option<u16>,
citation: Option<&AuthorityCitation>, at_ms) -> UnsignedEvent;
pub fn build_timer_notice(author, channel, epoch, seconds: u64, 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)
@@ -508,7 +520,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<Vec<(Epoch, GroupKey)>>;
pub fn fold(rumors: &[ChatRumor],
pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError>;
pub fn expired(rumor: &ChatRumor, now: Timestamp) -> bool;
pub fn fold(rumors: &[ChatRumor], now: Timestamp,
can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool)
-> Vec<ChatMessage>;
@@ -550,7 +564,9 @@ pub struct ChatMessage {
- `ms` orders a page but cannot page within one: a relay's `until` filter is second-granular,
so the cursor step below is what has to cope with a boundary second.
- `seal_rumor` gates the kind at publish and mirrors a NIP-40 `expiration` onto the wrap
(CORD-08 §2). The timer's policy — ingest refusal, the sweep, kind 1740 — is M8.
(CORD-08 §2), so a NIP-40 relay drops the ciphertext itself. Since M8 the durable builders
*attach* the tag from the folded timer, `fold` drops an expired rumor before displaying it, and
a delete or a timer notice carrying the tag is refused outright — see §8.8.
- **`media` and `mentions` are deliberately absent.** Both are pure post-processing of
`content` by `common` (`extract_and_remove_media_urls`, `NostrParser`) and both return a
gpui type, and a protocol crate does not take a UI dependency for a derived field. They
@@ -819,6 +835,96 @@ tombstone for one community be re-wrapped at another of theirs and kill it perma
community is sealed read-only: `CommunityState.dissolved` records it, subscriptions halt, nothing new is
honored, existing history stays readable, and a member's delete of their own message is still honored.
### 8.8 Pins and disappearing messages (`pins.rs`, `chat.rs`, `store.rs`) — implemented in M8
**Pins (CORD-04 §7).** One Pin List per Channel: `vsk 11` at `pins_locator(community_id, channel)`,
derived from the `community_id`, so it survives every refounding and a fresh joiner derives the same
coordinate. A pin does not quote a message, it *proves* one — the entry carries the original kind-20013
seal verbatim plus that message's 76-byte NIP-44 key disclosure, so a reader holding no history and no
old keys still verifies author, words, Channel and signed time.
```rust
pub const PIN_MAX_ENTRIES: usize = 25;
pub const PIN_MAX_CONTENT_BYTES: usize = 32_768;
pub const MESSAGE_KEYS_BYTES: usize = 76;
pub struct MessageKeys { /* chacha_key[32] ‖ chacha_nonce[12] ‖ hmac_key[32] */ }
pub struct PinEditBundle { seal: Event, keys: String }
pub struct PinEntry { seal: Event, keys: String, wrap: Option<String>, edit: Option<PinEditBundle>, extra }
pub struct VerifiedPin { rumor_id, author, kind, content, tags, epoch, at_ms, created_at, wrap, edited, entry }
pub struct ReadPinList { entries: Vec<PinEntry>, sealed: bool }
pub enum PinError { NotEncryptedSeal, BadPayload, Unverifiable, Unreadable, TooManyEntries, Oversize, Seal, Encode }
pub fn build_entry(opened: &OpenedStream, group: &GroupKey, channel: &ChannelId) -> Result<PinEntry, PinError>;
pub fn build_edit_bundle(edit: &OpenedStream, group, original: &VerifiedPin, channel) -> Result<PinEditBundle, PinError>;
pub fn with_proven_edit(entry: &PinEntry, edit: &OpenedStream, group, channel) -> PinEntry;
pub fn verify_entry(entry: &PinEntry, channel: &ChannelId) -> Option<VerifiedPin>;
pub fn read_list(content: &str, unseal: impl Fn(Epoch) -> Option<GroupKey>) -> ReadPinList;
pub fn publishable(read: &ReadPinList, private: bool, group: &GroupKey, epoch: Epoch) -> Result<String, PinError>;
pub fn killed_by(pin: &VerifiedPin, delete: &ChatRumor) -> bool;
```
- **The disclosure is a reproduction, not a re-use.** `nostr`'s `nip44::v2::get_message_keys` is a private
`fn` and both public entry points take the whole conversation key, so the expansion is rebuilt from the
same audited primitives: `hkdf::Hkdf::from_prk(conversation_key).expand(nonce, 76)`, split, then
HMAC-SHA256 over `nonce ‖ ciphertext` — compared in constant time, so the verify path is never a MAC
oracle — ChaCha20, and NIP-44's padding check. `MessageKeys::to_hex`/`from_hex` are the wire form
(lowercase-canonical), and one test round-trips the whole thing against nostr's own
`encrypt_to_bytes_with_nonce`, which is what keeps the copy honest.
- **The whole verification**, in order: the seal is kind 20013 and verifies → the entry's disclosure
decodes → MAC → decrypt → unpad → `UnsignedEvent::from_json` → the rumor's `pubkey` equals the seal's
(NIP-59's impersonation check) → the kind is `9` or `1111``channel` strict-equal to this list's
Channel → a canonical `epoch``verify_id()` and a *recomputed* identity. An Edit bundle failing any of
its own steps is dropped alone; the pin survives it.
- **Two deliberate divergences from Vector**, both stricter: the `epoch` tag is required (CORD-03 §3 makes
it mandatory and `chat::open` already enforces it) where Vector ignores it, and an out-of-range `ms` is
malformed rather than read as `0`, matching this crate's own reader. A duplicated `channel` tag resolves
to the first, as Vector does, because the rumor is author-signed and the lenient reading is not
exploitable.
- **Two self-describing content forms**, never signalled by the fold. Public is `{ "entries": [...] }`;
private is `{ "epoch": "<decimal>", "sealed": seal_bytes(...) }`, whose sealed plaintext is that same
`{ "entries": [...] }`. A reader accepts either form regardless of its metadata fold; a writer must use
the form matching the Channel's folded type.
- **The caps are content-level, never chain-level.** `read_list` yields an EMPTY list for oversize
content, an over-cap count, a malformed envelope or a failed decrypt, and `sealed: true` — darkness, not
violation — when the named epoch's key is missing. The edition itself always folds, so every client walks
the same chain.
- **`publishable` is the only write path**, and it refuses a dark list outright: an unreadable entry and
an absent one are indistinguishable, so re-forming would silently drop every entry the writer cannot see.
That is the write half of §12's "never publish from a list you could not read".
- The Edit bundle is the same five steps with kind `3302` substituted plus the fold's own two rules — the
proven author equals the original's, and the `e` tag names the original's recomputed rumor id — so a
keyless reader reaches the verdict a keyed reader reaches by folding. At most one, ever: a later Edit
*replaces* it, since edits target the original and never each other.
- Deferred to §10: §7's "a private→public conversion MUST NOT mechanically re-form the list" has no
encoding — "mechanically" and "deliberately" are the same bytes — so it is a duty of the conversion flow
(§14.13); the re-heal and deletion-omission writes (`killed_by` exists, the re-fold-and-republish loop
does not); and the automatic Edit refresh.
**Disappearing messages (CORD-08).**
- `CommunityMetadata.message_expiration: Option<u64>` is the timer in seconds, with a lenient
deserializer: absent, `0`, a string, a float or any other garbage all read as `None`, and garbage never
poisons the rest of the entity. The write side normalizes `Some(0)` away.
- The timer is community state, so the tag rides the **signed rumor**: `build_message`, `build_comment`,
`build_reaction` and `build_edit` take `timer: Option<u64>` and attach
`["expiration", created_at + timer]` from their own `at_ms`, which is what makes a change
non-retroactive. `seal_rumor` had mirrored the tag onto the wrap since M4, so a NIP-40 relay deletes the
ciphertext itself.
- **Two kinds are exempt**, and a rumor carrying the tag is refused with `ChatError::ExemptExpiration`:
a delete (its target may outlive it) and the timer notice (the policy must not be erased by the policy).
- **The timer notice** is kind `1740`: `build_timer_notice` writes it, `ChatAction::TimerNotice { seconds }`
reads it with a canonical `timer` tag, and the fold emits it as a row of its own, like any message.
Whether its author may be believed about policy is `MANAGE_METADATA` in the roster — the registry's call,
not the fold's, and `roles.is_authorized(&author, &owner, Permissions::MANAGE_METADATA)` already answers
it.
- **Enforcement lives in two places, both keyed on the rumor's own signed tag.** `chat::fold(rumors, now,
can_delete)` drops an expired rumor, so it is never displayed whatever the ingest path. `store::cache_rumor`
refuses to store one that has already expired (returning whether it kept it) and `backfill` both skips the
cache and drops it from the page. `store::purge_expired` is the sweep: it re-reads each cached row's
rumor, collects the expired ids and physically deletes them, because hiding is not disappearing and the
local store is the artifact a seized device surrenders. A malformed tag expires nothing.
## 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:
@@ -831,13 +937,18 @@ Three layers, no new storage engine:
- The layer takes `&dyn NostrDatabase`, not `&Client`: it is local-only, which keeps it testable without a relay or a GPUI context.
```rust
pub async fn cache_rumor(database: &dyn NostrDatabase, channel: &ChannelId, opened: &OpenedStream) -> Result<()>;
pub async fn cache_rumor(database: &dyn NostrDatabase, channel: &ChannelId, opened: &OpenedStream) -> Result<bool>;
pub async fn query_rumors(database: &dyn NostrDatabase, channel: &ChannelId, until: Option<Timestamp>, limit: usize) -> Result<Vec<UnsignedEvent>>;
pub async fn purge_expired(database: &dyn NostrDatabase, channel: &ChannelId, now: Timestamp) -> Result<usize>;
pub async fn backfill(client: &Client, database: &dyn NostrDatabase, channel: &ChannelId,
held: &[(Epoch, [u8; 32])], until: Option<Timestamp>, limit: usize)
-> Result<Vec<ChatRumor>>;
```
`cache_rumor` returned `()` until M8, when it gained the CORD-08 §3 ingest rule and with it a reason to
report: `false` means an already-expired rumor was refused and nothing was written. `cache_rumor` and
`purge_expired` are the two halves of the timer's storage policy; §8.8 has the rest.
`query_rumors` returns `UnsignedEvent`, not `Event`: the cached payload *is* a rumor, which is also what `OpenedStream` carries, so the caller never has to re-parse.
**Landed in M4:** `backfill` — newest-first relay paging across every held epoch. It derives
@@ -1030,14 +1141,16 @@ Each of these has burned a real implementation, or is a documented cross-client
- Drop guestbook entries more than an hour in the future; treat an out-of-range `ms` as malformed, not as an interpretation opportunity.
- Never honour a Snapshot from anyone but the refounder of that epoch.
- Refuse to write a Pin List from a list the writer could not read.
- 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.
- Enforce the NIP-44 65 535-byte cap at every layer before publishing, and the 5-relay / 50-membership / 100-role / 64-role-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 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.
- **Enforced in M7:** a blob's bound scope and epoch are checked inside the ciphertext, so a channel blob cannot be opened under the base scope or under another epoch, and a 136-byte base blob whose secret does not derive to the pk beside it is refused whole rather than adopting a split control plane; the locator is never gated, because it derives from public keys and proves nothing; a removal is never concluded from a partial chunk set, and two chunks claiming one index union their blobs rather than letting the loser's recipients read as removed; a rotation needs its permission and must strictly outrank every target, so a rotator holding the prior root or a demoted staffer holding the `control_root` is dropped; a plaintext-sealed rekey is refused; and a tombstone is refused unless its signed `eid` is this community's own id — the all-zero placeholder and a sibling community of the same owner included.
- 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.
- **Enforced in M8:** a plaintext seal carries no payload to disclose, so it cannot be pinned; a pin whose disclosure does not open its own seal is refused before it costs list budget, and a built entry is run through the same verification every reader applies; a `channel` or `epoch` bound to another Channel, a claimed rumor id that is not its own, another author's Edit, and a delete from anybody but the pin's own author are each refused; a list past 25 entries or 32 768 content bytes is refused on the write side and reads as EMPTY on every reader's; a sealed list that cannot be opened reads as darkness and never as an empty public one, and a writer can never re-form a list it could not read; a delete and a timer notice may not carry an `expiration` and a malformed `timer` is refused; an already-expired rumor is refused at ingest, dropped from every fold, and purged by the sweep.
- **The byte caps, closed out:** the NIP-44 plaintext cap is now checked inside `seal_bytes` as well as `seal_content`, so every raw envelope — the invite bundle, the sealed pin form, the List's to-self document — is inside it before it is published. 25 pins / 32 768 bytes; 100 roles and 64 roles per member, the member's cap also refusing the write; 500 banlist entries refused on the write side as well as capped in the fold; the 64-byte name cap refused on every community, channel and role write; 5 relays truncated on read as well as on write; 50 memberships in `list::fits`. The 256 in the old wording was the *invite bundle's* channel cap (M6), not a community-wide one — the spec states no community channel count, so none was invented.
- **Still owed:** the `vac`-carrying pin write and the 100-role write gate both need the roster, so both are the registry's (§10), as is the private→public re-form refusal (§14.13).
## 13. Milestones
@@ -1051,7 +1164,7 @@ Each of these has burned a real implementation, or is a documented cross-client
| 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 | ✅ `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 | ✅ `cargo test -p concord` (40 tests): a blob's bound scope and epoch are checked inside the ciphertext, so a channel blob cannot be opened under the base scope or under another epoch, and a staff secret that does not derive to the pk beside it refuses the whole blob; a removal is concluded only from a complete chunk set, and two chunks claiming one index union rather than drop a recipient's blob; continuity extends, gaps and forks, and the fork winner is the lowest key adopted only when it strictly lowers one already held; a rotation needs its permission and must strictly outrank every target, so holding a key is never authority; a full 80-blob chunk fits a 64 KB relay event and one more splits; compaction carries a settled head across a refounding with the original author's signature intact; and an owner's tombstone seals the community while an impostor's, the spec's all-zero `eid` and one re-wrapped from another community of the same owner are each 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 |
| M8 | Pins + disappearing messages + hardening | ✅ `cargo test -p concord` (49 tests): a disclosed 76-byte expansion opens exactly the message it was derived from and nothing else, pinned by a round-trip against nostr's own encrypt; a built entry proves its author and its words while tampered keys, a re-signed seal, a claimed id that is not its own and a Channel it does not belong to are all refused; a proven Edit replaces the words and a stranger's never attaches; both list forms round-trip with a sealed one dark without its key and lit with it, 26 entries refusing to build and reading back as empty, and a writer never re-forms a list it could not read; a Pin List folds under a derived coordinate for a second client while a neighbouring Channel reads none; a timer tag rides a durable rumor and its wrap while a delete and a notice carrying it are refused; the metadata timer is set, off and garbage without poisoning the entity; an expired rumor is refused at ingest and purged by the sweep while an untimed one never is; and the banlist, grant, channel-name and relay caps hold on the way out |
Ordering is deliberately dependency-first: each milestone is usable on its own, and nothing in M2+ depends on a later milestone. The sync engine and its GPUI wiring (§10, §11) have no row of their own because they are cross-cutting: every plane they consume has to exist first, so they follow M8, and they are the milestone that applies §11's `chat::handle_notifications` routing fix.
@@ -1077,8 +1190,12 @@ What M6 still defers, and to what: the **Invite List (13303) and the Registry (`
M7 closed at 40 tests, again with no dependency change and `Cargo.lock` untouched. New: `src/rekey.rs` (the blob atom, the 3303 chunk set and its collection, continuity and the fork winner, the authority gate, refounding planning and compaction, and dissolution). The Invite List landed in `src/invite.rs` beside the bundle it bookkeeps, and the Registry became a Control Plane entity: `ControlFold.registries` keyed by creator, folded under `CREATE_INVITE`, its aggregate exposed as `ControlFold::is_public`, with `ControlWriter::set_registry` as the write side. `invite_links_locator` and M6's revocation machinery needed no change. In `stream.rs`, `rewrap_seal` gained a separate `signer` group, because a split epoch reads under the rolled root but wraps as the new control signer. In `store.rs`, `CommunityState.dissolved` records the seal; `list.rs`'s `canonical`/`union` became `pub(crate)` so the Invite List's merge shares them rather than restating the same total order.
M8 closed at 49 tests. New: `src/pins.rs` (the disclosure primitive, the entry codec and its full verification, the Edit bundle, both content forms and their caps) and, in `control.rs`, `ControlFold.pins` keyed by locator with `pin_content`/`set_pin_list`, plus the metadata timer and the write-side caps. `chat.rs` gained kind 1740, `ChatAction::TimerNotice`, the `timer` argument on the four durable builders, the exemption refusal, `expired`, and a `now` parameter on `fold`; `store.rs` gained the ingest refusal and `purge_expired`; `stream.rs` gained the plaintext-cap check inside `seal_bytes`; `roles.rs` gained the 64-role write gate; and `lib.rs`'s `decode_hex_32` was generalized into `decode_hex_lower::<N>` so the 76-byte disclosure shares the one canonical-hex check rather than restating it. This is the first milestone that touched `Cargo.lock`: two direct-dependency edges (`chacha20`, `hmac`), no new package (§3). Evidence for one line of §12 that was wrong: the 256-channel cap does not exist in the spec — the 256 was `invite::MAX_BUNDLE_CHANNELS` all along.
What M7 still defers, and to what: **epoch key retention** — the blob atom delivers every plane key a refounding mints, but nothing can persist one, because `ChannelKeyRef` is `{id, name, private, epoch}` with no key field (§14.11); the **rekey subscription**, precomputed from the next epoch's address for every held private channel plus the base, which is a `Filter` and belongs with the sync engine (§10); the **Server-Severing flow** that would set `severed`, which awaits the invite-link wiring; and the **`Refound` seed** to `complete_memberlist`, which M7 can now mint but whose consumer is still the guestbook ingest of §10.
What M8 still defers, and to what: the **`vac`-carrying pin write, the 100-role write gate and the timer notice's roster gate** — all three need the roster, so they are the registry's (§10); the **private→public re-form refusal**, which cannot be encoded at the list level (§14.13); the **re-heal and deletion-omission pin writes** and the automatic Edit refresh, which are pins §7 behaviours a curator flow drives; and everything §10 already owed — the sync engine, the `chat::handle_notifications` routing fix, and the §14.11 key-retention schema change.
**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
@@ -1090,14 +1207,16 @@ What M7 still defers, and to what: **epoch key retention** — the blob atom del
5. **Relay set.** Up to 5 recommended, and both reads and writes fan out across them. Coop's client is a gossip client with `no_background_refresh`, so community relays must be added explicitly and re-added on metadata change.
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).
8. **The Pin List's message-key disclosure had no public API — resolved in M8.** `nostr`'s `nip44::v2::get_message_keys` is a private `fn`, so the expansion is reproduced in `pins.rs` over the same audited primitives — `hkdf`'s `from_prk`/`expand`, `hmac`'s constant-time `verify_slice`, `chacha20` — as two direct-dependency lines on packages already in the graph (§3), pinned by a round-trip against nostr's own `encrypt_to_bytes_with_nonce`. That test is the whole contract: if the reproduction ever drifts, it fails loudly rather than silently unverifying every pin. A `pub` accessor upstream remains strictly better than a copy we must keep in sync and is still a viable PR, but it is now an improvement rather than a blocker.
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 M4M8 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.
11. **No plane key can be persisted (found in M7).** The rekey blob atom hands a receiver every key a rotation mints — the next `community_root`, the `control_root`, and a private channel's fresh key — and `CommunityState` has nowhere to put any of them: `ChannelKeyRef` is `{id, name, private, epoch}`, and the state's only root fields are the *current* `community_root`/`root_epoch`. So a client can verify a rotation and still lose it on restart, and it cannot read history written under a prior root or a prior channel epoch. M7 therefore left `epoch_keys` out rather than add a field with no key to hold. The fix is a schema change — `ChannelKeyRef` gains the key and its retired `priors`, and the state gains a root-per-epoch map — and it belongs with the sync engine that reads them back, since it changes `apply_fold` and the `13302` join material together. Armada already carries `priors` for exactly this reason.
12. **`message_expiration` normalization is lossy, deliberately.** A `0`, a float or a string all fold to `None`, and a republish of that fold writes the field away rather than carrying the garbage through. CORD-08 §1 says malformed means off and a reader must not guess, so the value is uninterpretable by construction — but every other content struct in this crate round-trips bytes it does not understand via `extra`, and this field does not. Nothing depends on the distinction yet; giving an uninterpretable timer an `extra` slot is not worth it until something does.
13. **The private→public Pin List rule has no encoding.** §7 says a list MUST NOT be *mechanically* re-formed across a private→public conversion, because the pre-switch entries are private-era and a re-form republishes them community-wide — but "mechanically" and "deliberately" are the same bytes, so no reader-side check can tell them apart and `pins::publishable` does not pretend to. It refuses only what it can prove: a list it could not read. The refusal is therefore a duty of the conversion flow (§10), which must not auto-republish on a `private: false` metadata change.
## 15. Test strategy
- **Unit, pure:** derivations against golden vectors, edition hash, fold, coalesce, memberlist, blob codec, caps and rejection paths. These need no GPUI context and should be exhaustive — they are where cross-client divergence is caught.
- **Unit, pure:** derivations against golden vectors, edition hash, fold, coalesce, memberlist, blob codec, the NIP-44 disclosure, caps and rejection paths. These need no GPUI context and should be exhaustive — they are where cross-client divergence is caught.
- **Integration, GPUI:** `TestAppContext` with two registries sharing an in-memory database, driving wraps through the ingest path; timeouts and delays use `cx.background_executor().timer(..)` per the project guidelines, never `smol::Timer`.
- **Round-trip:** every builder paired with its parser, asserting the parse produces the identical structure, including unknown-field round-tripping on all content types.
- **Negative:** every bullet in §12 gets a test that constructs the hostile input and asserts the drop.
+2
View File
@@ -10,6 +10,8 @@ nostr-sdk.workspace = true
hkdf.workspace = true
sha2.workspace = true
chacha20.workspace = true
hmac.workspace = true
data-encoding.workspace = true
rand.workspace = true
serde.workspace = true
+253 -22
View File
@@ -22,6 +22,7 @@ pub const KIND_REACTION: u16 = 7;
pub const KIND_DELETE: u16 = 5;
pub const KIND_EDIT: u16 = 3302;
pub const KIND_FILE: u16 = 15;
pub const KIND_TIMER_NOTICE: u16 = 1740;
pub const KIND_WEBXDC: u16 = 3310;
pub const KIND_TYPING: u16 = 23311;
@@ -33,6 +34,7 @@ const TAG_ROOT_KIND: &str = "K";
const TAG_ROOT_AUTHOR: &str = "P";
const TAG_TARGET_AUTHOR: &str = "p";
const TAG_EXPIRATION: &str = "expiration";
const TAG_TIMER: &str = "timer";
#[derive(Debug)]
pub enum ChatError {
@@ -42,6 +44,9 @@ pub enum ChatError {
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
/// A delete is a tombstone and a timer notice documents the policy, so
/// neither may be erased by the policy it carries.
ExemptExpiration,
}
impl fmt::Display for ChatError {
@@ -53,6 +58,9 @@ impl fmt::Display for ChatError {
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
ChatError::ExemptExpiration => {
write!(f, "a delete or timer notice must not carry an expiration")
}
}
}
}
@@ -102,6 +110,9 @@ pub enum ChatAction {
},
Typing,
Opaque,
TimerNotice {
seconds: u64,
},
}
#[derive(Debug, Clone)]
@@ -142,6 +153,7 @@ pub fn build_message(
content: &str,
quote: Option<&ReplyRef>,
at_ms: u64,
timer: Option<u64>,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
@@ -149,11 +161,14 @@ pub fn build_message(
tags.push(reply_tag(TAG_QUOTE, quote));
}
tags.extend(expiration_tag(at_ms, timer));
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
}
/// A NIP-22 comment. `parent` is the immediate parent and `root` the thread's
/// immutable root; `None` means the parent is itself the root.
#[allow(clippy::too_many_arguments)]
pub fn build_comment(
author: PublicKey,
channel: &ChannelId,
@@ -162,6 +177,7 @@ pub fn build_comment(
parent: &Target,
root: Option<&Target>,
at_ms: u64,
timer: Option<u64>,
) -> UnsignedEvent {
let root = root.unwrap_or(parent);
let mut tags = channel_binding_tags(channel, epoch);
@@ -178,6 +194,8 @@ pub fn build_comment(
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
}
tags.extend(expiration_tag(at_ms, timer));
build_rumor_ms(KIND_COMMENT, author, content, tags, at_ms)
}
@@ -188,6 +206,7 @@ pub fn build_reaction(
target: &Target,
emoji: &str,
at_ms: u64,
timer: Option<u64>,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
@@ -197,6 +216,8 @@ pub fn build_reaction(
}
tags.push(Tag::custom(TAG_TARGET_KIND, [target.kind.to_string()]));
tags.extend(expiration_tag(at_ms, timer));
build_rumor_ms(KIND_REACTION, author, emoji, tags, at_ms)
}
@@ -207,13 +228,37 @@ pub fn build_edit(
target: EventId,
content: &str,
at_ms: u64,
timer: Option<u64>,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
tags.extend(expiration_tag(at_ms, timer));
build_rumor_ms(KIND_EDIT, author, content, tags, at_ms)
}
/// CORD-08 §4: an informational row in the timeline, gated by the roster rather
/// than by the fold, so it is built like any other chat rumor.
pub fn build_timer_notice(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
seconds: u64,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TIMER, [seconds.to_string()]));
build_rumor_ms(KIND_TIMER_NOTICE, author, "", tags, at_ms)
}
/// The tag is derived from the rumor's own signed `created_at`, so a later
/// metadata edit can never reach back into history.
fn expiration_tag(at_ms: u64, timer: Option<u64>) -> Option<Tag> {
timer.map(|timer| Tag::custom(TAG_EXPIRATION, [(at_ms / 1000 + timer).to_string()]))
}
pub fn build_delete(
author: PublicKey,
channel: &ChannelId,
@@ -327,6 +372,7 @@ pub fn plane_keys(
pub fn fold(
rumors: &[ChatRumor],
now: Timestamp,
can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool,
) -> Vec<ChatMessage> {
let mut order: Vec<usize> = (0..rumors.len()).collect();
@@ -338,12 +384,20 @@ pub fn fold(
for index in order {
let rumor = &rumors[index];
let ChatAction::Message {
reply_to,
thread_root,
} = &rumor.action
else {
if expired(rumor, now) {
continue;
}
let (reply_to, thread_root) = match &rumor.action {
ChatAction::Message {
reply_to,
thread_root,
} => (
reply_to.map(|reply| reply.id),
thread_root.map(|reply| reply.id),
),
ChatAction::TimerNotice { .. } => (None, None),
_ => continue,
};
slot.insert(rumor.id, messages.len());
@@ -354,8 +408,8 @@ pub fn fold(
epoch: rumor.epoch,
kind: rumor.kind,
content: rumor.content.clone(),
reply_to: reply_to.map(|reply| reply.id),
thread_root: thread_root.map(|reply| reply.id),
reply_to,
thread_root,
at_ms: rumor.at_ms,
expiration: rumor.expiration,
edited_at: None,
@@ -404,7 +458,10 @@ pub fn fold(
messages[slot].reactions.insert(rumor.author, emoji.clone());
}
ChatAction::Message { .. } | ChatAction::Typing | ChatAction::Opaque => {}
ChatAction::Message { .. }
| ChatAction::Typing
| ChatAction::TimerNotice { .. }
| ChatAction::Opaque => {}
}
}
@@ -413,6 +470,11 @@ pub fn fold(
messages
}
/// CORD-08 §3: an expired rumor is never displayed, whatever its ingest path.
pub fn expired(rumor: &ChatRumor, now: Timestamp) -> bool {
rumor.expiration.is_some_and(|expiration| expiration <= now)
}
fn is_chat_kind(kind: u16) -> bool {
matches!(
kind,
@@ -422,12 +484,19 @@ fn is_chat_kind(kind: u16) -> bool {
| KIND_DELETE
| KIND_EDIT
| KIND_FILE
| KIND_TIMER_NOTICE
| KIND_WEBXDC
| KIND_TYPING
)
}
fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<ChatRumor, ChatError> {
let expiration = expiration_of(rumor)?;
if expiration.is_some() && matches!(rumor.kind.as_u16(), KIND_DELETE | KIND_TIMER_NOTICE) {
return Err(ChatError::ExemptExpiration);
}
Ok(ChatRumor {
id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
author: rumor.pubkey,
@@ -436,7 +505,7 @@ fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<Cha
epoch,
at_ms: resolve_ms_strict(rumor)?,
content: rumor.content.clone(),
expiration: expiration_of(rumor)?,
expiration,
action: action_of(rumor)?,
})
}
@@ -467,11 +536,20 @@ fn action_of(rumor: &UnsignedEvent) -> Result<ChatAction, ChatError> {
citation: optional_citation(rumor)?,
}),
KIND_TYPING => Ok(ChatAction::Typing),
KIND_TIMER_NOTICE => Ok(ChatAction::TimerNotice {
seconds: timer_of(rumor)?,
}),
KIND_WEBXDC => Ok(ChatAction::Opaque),
other => Err(ChatError::UnknownKind(other)),
}
}
fn timer_of(rumor: &UnsignedEvent) -> Result<u64, ChatError> {
let fields = tag(rumor, TAG_TIMER)?.ok_or(ChatError::MissingTag(TAG_TIMER))?;
canonical_decimal(value(fields, TAG_TIMER)?).ok_or(ChatError::BadTag(TAG_TIMER))
}
fn optional_reply(
rumor: &UnsignedEvent,
name: &'static str,
@@ -520,7 +598,7 @@ fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>,
.ok_or(ChatError::BadTag(TAG_CITATION))
}
fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None);
};
@@ -594,6 +672,11 @@ mod tests {
const SECRET: [u8; 32] = [0x2du8; 32];
const AT: u64 = 1_700_000_000_417;
/// Well past every timestamp these tests use.
fn now() -> Timestamp {
Timestamp::from_secs(2_000_000_000)
}
fn channel() -> ChannelId {
ChannelId::from_bytes([0x9cu8; 32])
}
@@ -628,7 +711,15 @@ mod tests {
let carol = Keys::generate();
let group = group();
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let message = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"hello",
None,
AT,
None,
);
let id = message.compute_id();
let rumors = vec![
@@ -641,6 +732,7 @@ mod tests {
&target(id, &alice),
"🔥",
AT + 1_000,
None,
),
&group,
&carol,
@@ -654,6 +746,7 @@ mod tests {
id,
"hello (fixed)",
AT + 2_000,
None,
),
&group,
&alice,
@@ -675,7 +768,7 @@ mod tests {
),
];
let folded = fold(&rumors, |_, _, _| false);
let folded = fold(&rumors, now(), |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].id, id);
@@ -694,7 +787,15 @@ mod tests {
let bob = Keys::generate();
let group = group();
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let message = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"hello",
None,
AT,
None,
);
let id = message.compute_id();
let rumors = vec![
@@ -707,6 +808,7 @@ mod tests {
id,
"mine now",
AT + 1_000,
None,
),
&group,
&bob,
@@ -728,7 +830,7 @@ mod tests {
),
];
let folded = fold(&rumors, |_, _, _| false);
let folded = fold(&rumors, now(), |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].content, "hello");
@@ -742,7 +844,15 @@ mod tests {
let bob = Keys::generate();
let group = group();
let root = build_message(alice.public_key(), &channel(), Epoch(0), "root", None, AT);
let root = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"root",
None,
AT,
None,
);
let root_id = root.compute_id();
let parent = build_message(
bob.public_key(),
@@ -751,6 +861,7 @@ mod tests {
"parent",
None,
AT + 1_000,
None,
);
let parent_id = parent.compute_id();
@@ -762,6 +873,7 @@ mod tests {
&target(parent_id, &bob),
Some(&target(root_id, &alice)),
AT + 2_000,
None,
);
assert!(comment.tags.iter().any(|tag| tag.as_slice() == ["K", "9"]));
@@ -796,7 +908,15 @@ mod tests {
let alice = Keys::generate();
let group = group();
let plain = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let plain = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"hello",
None,
AT,
None,
);
assert!(
open(
&sealed(&plain, &group, &alice),
@@ -820,7 +940,15 @@ mod tests {
Err(ChatError::Stream(StreamError::ChannelMismatch))
));
let stale = build_message(alice.public_key(), &channel(), Epoch(1), "stale", None, AT);
let stale = build_message(
alice.public_key(),
&channel(),
Epoch(1),
"stale",
None,
AT,
None,
);
assert!(matches!(
open(
&sealed(&stale, &group, &alice),
@@ -882,7 +1010,15 @@ mod tests {
let group = group();
let message = read(
&build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT),
&build_message(
alice.public_key(),
&channel(),
Epoch(0),
"hello",
None,
AT,
None,
),
&group,
&alice,
Epoch(0),
@@ -922,26 +1058,121 @@ mod tests {
ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation
));
assert!(
fold(&cited, can_delete)[0].deleted,
fold(&cited, now(), can_delete)[0].deleted,
"a cited moderator delete lands"
);
let uncited = vec![message.clone(), delete(&moderator, None)];
assert!(
!fold(&uncited, can_delete)[0].deleted,
!fold(&uncited, now(), 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,
!fold(&peer_delete, now(), 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,
fold(&own, now(), |_, _, _| false)[0].deleted,
"a self-delete never consults the predicate"
);
}
#[test]
fn a_timer_rides_durable_rumors_and_expiry_gates_the_fold() {
let alice = Keys::generate();
let group = group();
let expires = (AT / 1000 + 60).to_string();
// Computed from the signed `created_at`, and mirrored onto the wrap so
// relays drop the ciphertext too.
let message = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"tick",
None,
AT,
Some(60),
);
assert!(
message
.tags
.iter()
.any(|tag| tag.as_slice() == [TAG_EXPIRATION, expires.as_str()])
);
assert!(
sealed(&message, &group, &alice)
.tags
.iter()
.any(|tag| tag.as_slice() == [TAG_EXPIRATION, expires.as_str()])
);
let live = read(&message, &group, &alice, Epoch(0));
assert_eq!(live.expiration, Some(Timestamp::from_secs(AT / 1000 + 60)));
assert!(!expired(&live, Timestamp::from_secs(AT / 1000 + 59)));
assert!(expired(&live, Timestamp::from_secs(AT / 1000 + 60)));
assert_eq!(
fold(
std::slice::from_ref(&live),
Timestamp::from_secs(AT / 1000 + 59),
|_, _, _| false
)
.len(),
1
);
assert_eq!(
fold(&[live], Timestamp::from_secs(AT / 1000 + 60), |_, _, _| {
false
})
.len(),
0
);
// A delete is a tombstone and a notice documents the policy, so neither
// may be erased by the policy it carries.
let mut expiring = channel_binding_tags(&channel(), Epoch(0));
expiring.push(Tag::custom(TAG_EXPIRATION, ["1"]));
expiring.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)]));
for kind in [KIND_DELETE, KIND_TIMER_NOTICE] {
let rumor = build_rumor_ms(kind, alice.public_key(), "", expiring.clone(), AT);
assert!(matches!(
open(
&sealed(&rumor, &group, &alice),
&group,
&channel(),
Epoch(0)
),
Err(ChatError::ExemptExpiration)
));
}
// A notice is a row of its own; whether its author may be believed
// about policy is the roster's call, not the fold's.
let notice = build_timer_notice(alice.public_key(), &channel(), Epoch(0), 3_600, AT);
let folded = fold(
&[read(&notice, &group, &alice, Epoch(0))],
now(),
|_, _, _| false,
);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].kind, Kind::Custom(KIND_TIMER_NOTICE));
let mut malformed = channel_binding_tags(&channel(), Epoch(0));
malformed.push(Tag::custom(TAG_TIMER, ["060"]));
let rumor = build_rumor_ms(KIND_TIMER_NOTICE, alice.public_key(), "", malformed, AT);
assert!(matches!(
open(
&sealed(&rumor, &group, &alice),
&group,
&channel(),
Epoch(0)
),
Err(ChatError::BadTag(TAG_TIMER))
));
}
}
+295 -6
View File
@@ -6,14 +6,15 @@ use serde::{Deserialize, Serialize};
use crate::derive::{
banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator,
invite_links_locator, verify_community_id,
invite_links_locator, pins_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, Grant, Permissions, Role, Roster, citation_ok, fold_roster,
AuthorityEdition, CommunityRoles, Grant, MAX_BANLIST, 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};
@@ -49,12 +50,32 @@ pub struct CommunityMetadata {
pub icon: Option<ImageRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub banner: Option<ImageRef>,
/// CORD-08's disappearing-messages timer, in seconds.
#[serde(
default,
deserialize_with = "timer_seconds",
skip_serializing_if = "Option::is_none"
)]
pub message_expiration: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<Extra>,
#[serde(flatten)]
pub extra: Extra,
}
/// CORD-08 §1: absent, `0` and malformed all mean off, and a reader must not
/// guess a default from garbage.
fn timer_seconds<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(value
.and_then(|value| value.as_u64())
.filter(|seconds| *seconds > 0))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ChannelMetadata {
pub name: String,
@@ -249,6 +270,10 @@ impl ControlWriter {
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
if metadata.name.len() > MAX_NAME_BYTES {
bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
}
let content = serde_json::to_string(metadata)?;
self.publish(
@@ -272,6 +297,10 @@ impl ControlWriter {
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
if role.name.len() > MAX_NAME_BYTES {
bail!("role name exceeds {MAX_NAME_BYTES} bytes");
}
let content = role.to_content()?;
self.publish(
@@ -320,6 +349,10 @@ impl ControlWriter {
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
if banned.len() > MAX_BANLIST {
bail!("banlist exceeds {MAX_BANLIST} entries");
}
let entries: Vec<String> = banned.iter().map(PublicKey::to_hex).collect();
let content = serde_json::to_string(&entries)?;
@@ -366,6 +399,32 @@ impl ControlWriter {
at_secs,
)
}
/// `content` is the whole Pin List, in whichever of CORD-04 §7's two
/// self-describing forms the Channel's folded type calls for.
#[allow(clippy::too_many_arguments)]
pub fn set_pin_list(
&self,
keys: &Keys,
community_id: &CommunityId,
channel: &ChannelId,
content: &str,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
self.publish(
keys,
Edition {
subkind: vsk::PINS,
entity: pins_locator(community_id, channel),
content,
head,
citation,
},
at_secs,
)
}
}
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
@@ -383,6 +442,7 @@ fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
let mut metadata = metadata.clone();
metadata.relays.truncate(MAX_RELAYS);
metadata.message_expiration = metadata.message_expiration.filter(|seconds| *seconds > 0);
Ok(serde_json::to_string(&metadata)?)
}
@@ -395,6 +455,9 @@ pub struct ControlFold {
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
/// Each creator's live link-signer set.
pub registries: BTreeMap<PublicKey, Vec<PublicKey>>,
/// Head content per `pins_locator`: a Pin List is addressed by a one-way
/// coordinate, so a fold cannot name the Channel it belongs to.
pub pins: BTreeMap<[u8; 32], String>,
pub floors: Floors,
pub gapped: bool,
}
@@ -403,6 +466,12 @@ impl ControlFold {
pub fn is_public(&self) -> bool {
self.registries.values().any(|links| !links.is_empty())
}
pub fn pin_content(&self, community_id: &CommunityId, channel: &ChannelId) -> Option<&str> {
self.pins
.get(&pins_locator(community_id, channel))
.map(String::as_str)
}
}
pub fn fold_control(
@@ -429,6 +498,7 @@ pub fn fold_control(
community: metadata.community,
channels: metadata.channels,
registries: metadata.registries,
pins: metadata.pins,
floors,
gapped: roster.gapped || metadata.gapped,
}
@@ -439,6 +509,7 @@ struct MetadataFold {
community: Option<CommunityMetadata>,
channels: BTreeMap<ChannelId, ChannelMetadata>,
registries: BTreeMap<PublicKey, Vec<PublicKey>>,
pins: BTreeMap<[u8; 32], String>,
floors: Floors,
gapped: bool,
}
@@ -483,7 +554,14 @@ fn fold_metadata(
Permissions::MANAGE_METADATA,
&mut fold.gapped,
) {
fold.community = serde_json::from_str(&head.content).ok();
fold.community = serde_json::from_str::<CommunityMetadata>(&head.content)
.ok()
.map(|mut metadata| {
// Up to 5 relays is a recommendation, so a longer set is
// truncated rather than refused, on read as well as on write.
metadata.relays.truncate(MAX_RELAYS);
metadata
});
fold.floors.insert(head.entity, EntityHead::from(head));
}
@@ -507,10 +585,44 @@ fn fold_metadata(
}
fold.registries = fold_registries(&judge, editions, &mut fold.floors, &mut fold.gapped);
fold.pins = fold_pins(&judge, editions, &mut fold.floors, &mut fold.gapped);
fold
}
/// A Pin List's coordinate derives one-way, so unlike the banlist, a grant or a
/// registry there is nothing to check the `eid` against: an edition at an
/// unknown coordinate is simply never read. Its content is stored verbatim,
/// because a violating list still folds but reads as empty (CORD-04 §7).
fn fold_pins(
judge: &Judge<'_>,
editions: &[ParsedEdition],
floors: &mut Floors,
gapped: &mut bool,
) -> BTreeMap<[u8; 32], String> {
let mut candidates: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for edition in editions {
if edition.subkind == vsk::PINS {
candidates.entry(edition.entity).or_default().push(edition);
}
}
let mut pins = BTreeMap::new();
for (entity, group) in &candidates {
let Some(head) = authorized_head(judge, *entity, group, Permissions::PIN_MESSAGES, gapped)
else {
continue;
};
floors.insert(*entity, EntityHead::from(head));
pins.insert(*entity, head.content.clone());
}
pins
}
fn fold_registries(
judge: &Judge<'_>,
editions: &[ParsedEdition],
@@ -630,11 +742,12 @@ mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::derive::grant_locator;
use crate::chat::{self, build_message, seal_rumor};
use crate::derive::{channel_group_key, grant_locator};
use crate::edition::fold;
use crate::roles::{Grant, Role, RoleScope};
use crate::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
use crate::store::{CommunityState, load_state, save_state};
use crate::{Extra, RoleId};
use crate::{Extra, RoleId, pins};
const AT: u64 = 1_700_000_000;
@@ -976,4 +1089,180 @@ mod tests {
Some("coop by mod")
);
}
#[test]
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let channel = minted.channel_id;
let group =
channel_group_key(&minted.community_root, &channel, ROOT_EPOCH).expect("derives");
let author = Keys::generate();
let rumor = build_message(
author.public_key(),
&channel,
ROOT_EPOCH,
"pin me",
None,
AT * 1_000,
None,
);
let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals");
let opened = chat::open(&wrap, &group, &channel, ROOT_EPOCH)
.expect("opens")
.0;
let entry = pins::build_entry(&opened, &group, &channel).expect("pins");
let content = pins::publishable(
&pins::ReadPinList {
entries: vec![entry],
sealed: false,
},
false,
&group,
ROOT_EPOCH,
)
.expect("publishes");
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let (pin_wrap, _) = writer
.set_pin_list(
&owner,
&community_id,
&channel,
&content,
None,
None,
AT + 1,
)
.expect("publishes");
let mut editions = open_all(&minted.wraps, &read, &signer.pk());
editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&editions,
&Floors::new(),
&BTreeSet::new(),
);
// The coordinate derives one-way, so the list is found by naming the Channel.
let found = pins::read_list(
folded
.pin_content(&community_id, &channel)
.expect("the list folds"),
|_| None,
);
assert_eq!(found.entries.len(), 1);
assert_eq!(
pins::verify_entry(&found.entries[0], &channel)
.expect("verifies")
.content,
"pin me"
);
let other = ChannelId::from_bytes([0x77; 32]);
assert!(folded.pin_content(&community_id, &other).is_none());
}
#[test]
fn the_timer_is_never_guessed_and_the_write_caps_hold() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let writer = ControlWriter {
author: owner_pk,
read,
signer,
};
let fold = |metadata: &CommunityMetadata| {
fold_control(
&owner_pk,
&community_id,
&open_all(
&[writer
.set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1)
.expect("publishes")
.0],
&writer.read,
&writer.signer.pk(),
),
&Floors::new(),
&BTreeSet::new(),
)
.community
.expect("folds")
};
let mut timed = metadata("coop");
timed.message_expiration = Some(2_592_000);
assert_eq!(fold(&timed).message_expiration, Some(2_592_000));
// Absent, zero and garbage all mean off, and garbage never poisons the rest.
assert_eq!(fold(&metadata("coop")).message_expiration, None);
let mut off = metadata("coop");
off.message_expiration = Some(0);
assert_eq!(fold(&off).message_expiration, None);
let garbage = serde_json::json!({
"name": "coop",
"message_expiration": "later",
})
.to_string();
let folded: CommunityMetadata = serde_json::from_str(&garbage).expect("parses");
assert_eq!(folded.name, "coop");
assert_eq!(folded.message_expiration, None);
// The caps the folds apply also hold on the way out.
let banned: BTreeSet<PublicKey> = (0..=MAX_BANLIST)
.map(|_| Keys::generate().public_key())
.collect();
assert!(
writer
.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)
.is_err()
);
let grant = Grant {
member: owner_pk,
role_ids: (0..=MAX_ROLES_PER_MEMBER)
.map(|index| RoleId::from_bytes([index as u8; 32]))
.collect(),
control_wrap: None,
extra: Extra::default(),
};
assert!(grant.to_content().is_err());
assert!(
writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "x".repeat(MAX_NAME_BYTES + 1),
private: false,
..ChannelMetadata::default()
},
None,
None,
AT + 3,
)
.is_err()
);
}
}
+7 -2
View File
@@ -5,6 +5,7 @@ pub mod edition;
pub mod guestbook;
pub mod invite;
pub mod list;
pub mod pins;
pub mod rekey;
pub mod roles;
pub mod store;
@@ -125,14 +126,18 @@ impl fmt::Display for Epoch {
/// Uppercase and other non-canonical spellings are rejected.
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
decode_hex_lower::<32>(value)
}
pub(crate) fn decode_hex_lower<const N: usize>(value: &str) -> Result<[u8; N]> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.map_err(|error| anyhow!("invalid hex: {error}"))?;
let decoded: [u8; 32] = bytes
let decoded: [u8; N] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))?;
.map_err(|_| anyhow!("expected {N} bytes, got {}", bytes.len()))?;
if HEXLOWER.encode(&decoded) != value {
bail!("hex must be lowercase and canonical");
+825
View File
@@ -0,0 +1,825 @@
use std::fmt;
use chacha20::ChaCha20;
use chacha20::cipher::{KeyIvInit, StreamCipher};
use data_encoding::{BASE64, HEXLOWER};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::chat::{ChatAction, ChatRumor, KIND_COMMENT, KIND_EDIT, KIND_MESSAGE};
use crate::edition::canonical_decimal;
use crate::stream::{self, OpenedStream, SealForm, resolve_ms_strict};
use crate::{ChannelId, Epoch, Extra, GroupKey, decode_hex_lower};
pub const PIN_MAX_ENTRIES: usize = 25;
pub const PIN_MAX_CONTENT_BYTES: usize = 32_768;
/// The serialized disclosure: `chacha_key[32] || chacha_nonce[12] || hmac_key[32]`.
pub const MESSAGE_KEYS_BYTES: usize = 76;
const TAG_CHANNEL: &str = "channel";
const TAG_EPOCH: &str = "epoch";
const TAG_TARGET: &str = "e";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinError {
NotEncryptedSeal,
BadPayload,
Unverifiable,
Unreadable,
TooManyEntries,
Oversize(usize),
Seal(String),
Encode(String),
}
impl fmt::Display for PinError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PinError::NotEncryptedSeal => write!(f, "pin requires an encrypted seal"),
PinError::BadPayload => write!(f, "the seal payload does not open"),
PinError::Unverifiable => write!(f, "the entry would not verify"),
PinError::Unreadable => {
write!(f, "refusing to publish a pin list this client cannot read")
}
PinError::TooManyEntries => write!(f, "pin list exceeds {PIN_MAX_ENTRIES} entries"),
PinError::Oversize(len) => {
write!(
f,
"pin list content is {len} bytes (cap {PIN_MAX_CONTENT_BYTES})"
)
}
PinError::Seal(error) => write!(f, "seal: {error}"),
PinError::Encode(error) => write!(f, "encode: {error}"),
}
}
}
impl std::error::Error for PinError {}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct MessageKeys {
chacha_key: [u8; 32],
chacha_nonce: [u8; 12],
hmac_key: [u8; 32],
}
impl fmt::Debug for MessageKeys {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("MessageKeys(<disclosed>)")
}
}
impl MessageKeys {
pub fn to_hex(&self) -> String {
let mut packed = [0u8; MESSAGE_KEYS_BYTES];
packed[0..32].copy_from_slice(&self.chacha_key);
packed[32..44].copy_from_slice(&self.chacha_nonce);
packed[44..76].copy_from_slice(&self.hmac_key);
HEXLOWER.encode(&packed)
}
pub fn from_hex(value: &str) -> Option<Self> {
let bytes = decode_hex_lower::<MESSAGE_KEYS_BYTES>(value).ok()?;
Some(Self {
chacha_key: bytes[0..32].try_into().ok()?,
chacha_nonce: bytes[32..44].try_into().ok()?,
hmac_key: bytes[44..76].try_into().ok()?,
})
}
fn derive(conversation_key: &[u8; 32], nonce: &[u8]) -> Option<Self> {
let hkdf = Hkdf::<Sha256>::from_prk(conversation_key).ok()?;
let mut key_material = [0u8; MESSAGE_KEYS_BYTES];
hkdf.expand(nonce, &mut key_material).ok()?;
Some(Self {
chacha_key: key_material[0..32].try_into().ok()?,
chacha_nonce: key_material[32..44].try_into().ok()?,
hmac_key: key_material[44..76].try_into().ok()?,
})
}
}
struct Payload {
nonce: [u8; 32],
ciphertext: Vec<u8>,
mac: [u8; 32],
}
fn decode_payload(payload: &str) -> Option<Payload> {
let data = BASE64.decode(payload.as_bytes()).ok()?;
if data.len() < 99 || data[0] != 2 {
return None;
}
let mac_at = data.len() - 32;
Some(Payload {
nonce: data[1..33].try_into().ok()?,
ciphertext: data[33..mac_at].to_vec(),
mac: data[mac_at..].try_into().ok()?,
})
}
fn disclose_keys(payload: &str, conversation_key: &[u8; 32]) -> Option<MessageKeys> {
let decoded = decode_payload(payload)?;
MessageKeys::derive(conversation_key, &decoded.nonce)
}
fn open_payload(payload: &str, keys: &MessageKeys) -> Option<String> {
let decoded = decode_payload(payload)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&keys.hmac_key).ok()?;
mac.update(&decoded.nonce);
mac.update(&decoded.ciphertext);
mac.verify_slice(&decoded.mac).ok()?;
let mut padded = decoded.ciphertext;
let mut cipher = ChaCha20::new((&keys.chacha_key).into(), (&keys.chacha_nonce).into());
cipher.apply_keystream(&mut padded);
unpad(&padded)
}
fn unpad(padded: &[u8]) -> Option<String> {
let (len, prefix) = plaintext_length(padded)?;
let unpadded = padded.get(prefix..prefix.checked_add(len)?)?;
if len < 1 || padded.len() != prefix.checked_add(padded_len(len)?)? {
return None;
}
String::from_utf8(unpadded.to_vec()).ok()
}
fn plaintext_length(padded: &[u8]) -> Option<(usize, usize)> {
let short = u16::from_be_bytes(padded.get(..2)?.try_into().ok()?);
if short != 0 {
return Some((short as usize, 2));
}
let long = u32::from_be_bytes(padded.get(2..6)?.try_into().ok()?);
if long < 65_536 {
return None;
}
Some((long as usize, 6))
}
fn padded_len(len: usize) -> Option<usize> {
if len < 1 {
return None;
}
if len <= 32 {
return Some(32);
}
let next_power = 1usize.checked_shl(usize::BITS - (len - 1).leading_zeros())?;
let chunk = if next_power <= 256 {
32
} else {
next_power / 8
};
Some(chunk * ((len - 1) / chunk + 1))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PinEditBundle {
pub seal: Event,
pub keys: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PinEntry {
pub seal: Event,
pub keys: String,
/// An unverifiable locator hint; a mismatch is expected and never fatal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrap: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit: Option<PinEditBundle>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditedContent {
pub content: String,
pub at_ms: u64,
}
#[derive(Debug, Clone)]
pub struct VerifiedPin {
pub rumor_id: EventId,
pub author: PublicKey,
pub kind: u16,
pub content: String,
pub tags: Tags,
pub epoch: Epoch,
pub at_ms: u64,
pub created_at: u64,
pub wrap: Option<String>,
pub edited: Option<EditedContent>,
pub entry: PinEntry,
}
#[derive(Debug, Clone, Default)]
pub struct ReadPinList {
pub entries: Vec<PinEntry>,
pub sealed: bool,
}
pub fn build_entry(
opened: &OpenedStream,
group: &GroupKey,
channel: &ChannelId,
) -> Result<PinEntry, PinError> {
let keys = disclosed_keys(opened, group)?;
let entry = PinEntry {
seal: opened.seal.clone(),
keys: keys.to_hex(),
wrap: Some(opened.wrapper_id.to_hex()),
edit: None,
extra: Extra::default(),
};
if verify_entry(&entry, channel).is_none() {
return Err(PinError::Unverifiable);
}
Ok(entry)
}
pub fn build_edit_bundle(
edit: &OpenedStream,
group: &GroupKey,
original: &VerifiedPin,
channel: &ChannelId,
) -> Result<PinEditBundle, PinError> {
let bundle = PinEditBundle {
seal: edit.seal.clone(),
keys: disclosed_keys(edit, group)?.to_hex(),
};
if verify_edit_bundle(&bundle, &original.author, &original.rumor_id, channel).is_none() {
return Err(PinError::Unverifiable);
}
Ok(bundle)
}
pub fn with_proven_edit(
entry: &PinEntry,
edit: &OpenedStream,
group: &GroupKey,
channel: &ChannelId,
) -> PinEntry {
let Some(original) = verify_entry(entry, channel) else {
return entry.clone();
};
let Ok(bundle) = build_edit_bundle(edit, group, &original, channel) else {
return entry.clone();
};
let mut refreshed = entry.clone();
refreshed.edit = Some(bundle);
refreshed
}
fn disclosed_keys(opened: &OpenedStream, group: &GroupKey) -> Result<MessageKeys, PinError> {
if opened.seal_form != SealForm::Encrypted {
return Err(PinError::NotEncryptedSeal);
}
let conversation: [u8; 32] = group
.conversation()
.as_bytes()
.try_into()
.map_err(|_| PinError::BadPayload)?;
let keys = disclose_keys(&opened.seal.content, &conversation).ok_or(PinError::BadPayload)?;
if open_payload(&opened.seal.content, &keys).is_none() {
return Err(PinError::BadPayload);
}
Ok(keys)
}
pub fn verify_entry(entry: &PinEntry, channel: &ChannelId) -> Option<VerifiedPin> {
let seal = &entry.seal;
if seal.kind.as_u16() != stream::KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
return None;
}
let keys = MessageKeys::from_hex(&entry.keys)?;
let plaintext = open_payload(&seal.content, &keys)?;
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
// NIP-59's impersonation check: the renderer shows the rumor's fields.
if rumor.pubkey != seal.pubkey {
return None;
}
let kind = rumor.kind.as_u16();
if kind != KIND_MESSAGE && kind != KIND_COMMENT {
return None;
}
// CORD-01's binding, restated for a path that decrypts no wrap: without
// this, a private Channel's keyholder could pin its messages into a public
// list, disclosing them community-wide with proof.
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
return None;
}
let epoch = Epoch(canonical_decimal(tag_value(&rumor, TAG_EPOCH)?)?);
// Every reader recomputes the identity; a claimed `id` is never trusted.
rumor.verify_id().ok()?;
let rumor_id = rumor.compute_id();
let edited = entry
.edit
.as_ref()
.and_then(|bundle| verify_edit_bundle(bundle, &rumor.pubkey, &rumor_id, channel));
Some(VerifiedPin {
author: rumor.pubkey,
content: edited
.as_ref()
.map_or_else(|| rumor.content.clone(), |edited| edited.content.clone()),
epoch,
at_ms: resolve_ms_strict(&rumor).ok()?,
created_at: rumor.created_at.as_secs(),
tags: rumor.tags.clone(),
wrap: entry.wrap.clone(),
edited,
entry: entry.clone(),
kind,
rumor_id,
})
}
fn verify_edit_bundle(
bundle: &PinEditBundle,
original_author: &PublicKey,
original_id: &EventId,
channel: &ChannelId,
) -> Option<EditedContent> {
let seal = &bundle.seal;
// Nobody else may revise another member's words, and this is checkable
// before any crypto.
if seal.kind.as_u16() != stream::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
return None;
}
if seal.verify().is_err() {
return None;
}
let keys = MessageKeys::from_hex(&bundle.keys)?;
let plaintext = open_payload(&seal.content, &keys)?;
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
if rumor.pubkey != seal.pubkey || rumor.kind.as_u16() != KIND_EDIT {
return None;
}
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
return None;
}
if tag_value(&rumor, TAG_TARGET)? != original_id.to_hex() {
return None;
}
rumor.verify_id().ok()?;
Some(EditedContent {
content: rumor.content.clone(),
at_ms: resolve_ms_strict(&rumor).ok()?,
})
}
fn tag_value<'a>(rumor: &'a UnsignedEvent, name: &str) -> Option<&'a str> {
rumor
.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some(name))
.and_then(|tag| tag.as_slice().get(1))
.map(String::as_str)
}
#[derive(Serialize, Deserialize)]
struct PlainForm {
entries: Vec<PinEntry>,
}
pub fn publishable(
read: &ReadPinList,
private: bool,
group: &GroupKey,
epoch: Epoch,
) -> Result<String, PinError> {
if read.sealed {
return Err(PinError::Unreadable);
}
if private {
serialize_sealed(&read.entries, group, epoch)
} else {
serialize_public(&read.entries)
}
}
fn serialize_public(entries: &[PinEntry]) -> Result<String, PinError> {
let content = encode_form(entries)?;
check_caps(entries.len(), &content)?;
Ok(content)
}
fn serialize_sealed(
entries: &[PinEntry],
group: &GroupKey,
epoch: Epoch,
) -> Result<String, PinError> {
if entries.len() > PIN_MAX_ENTRIES {
return Err(PinError::TooManyEntries);
}
let inner = encode_form(entries)?;
let sealed = stream::seal_bytes(group.conversation(), inner.as_bytes())
.map_err(|error| PinError::Seal(error.to_string()))?;
let content = serde_json::json!({ "epoch": epoch.to_string(), "sealed": sealed }).to_string();
check_caps(entries.len(), &content)?;
Ok(content)
}
fn encode_form(entries: &[PinEntry]) -> Result<String, PinError> {
serde_json::to_string(&PlainForm {
entries: entries.to_vec(),
})
.map_err(|error| PinError::Encode(error.to_string()))
}
fn check_caps(count: usize, content: &str) -> Result<(), PinError> {
if count > PIN_MAX_ENTRIES {
return Err(PinError::TooManyEntries);
}
if content.len() > PIN_MAX_CONTENT_BYTES {
return Err(PinError::Oversize(content.len()));
}
Ok(())
}
pub fn read_list(content: &str, unseal: impl Fn(Epoch) -> Option<GroupKey>) -> ReadPinList {
const EMPTY: ReadPinList = ReadPinList {
entries: Vec::new(),
sealed: false,
};
if content.len() > PIN_MAX_CONTENT_BYTES {
return EMPTY;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
return EMPTY;
};
if value.get("entries").is_some() {
return match serde_json::from_value::<PlainForm>(value) {
Ok(form) if form.entries.len() <= PIN_MAX_ENTRIES => ReadPinList {
entries: form.entries,
sealed: false,
},
_ => EMPTY,
};
}
let (Some(epoch), Some(sealed)) = (
value.get("epoch").and_then(serde_json::Value::as_str),
value.get("sealed").and_then(serde_json::Value::as_str),
) else {
return EMPTY;
};
let Some(epoch) = canonical_decimal(epoch) else {
return EMPTY;
};
let Some(group) = unseal(Epoch(epoch)) else {
return ReadPinList {
sealed: true,
..EMPTY
};
};
let Ok(inner) = stream::open_bytes(group.conversation(), sealed) else {
return EMPTY;
};
let Ok(form) = serde_json::from_slice::<PlainForm>(&inner) else {
return EMPTY;
};
if form.entries.len() > PIN_MAX_ENTRIES {
return EMPTY;
}
ReadPinList {
entries: form.entries,
sealed: false,
}
}
pub fn killed_by(pin: &VerifiedPin, delete: &ChatRumor) -> bool {
delete.author == pin.author
&& matches!(&delete.action, ChatAction::Delete { target, .. } if *target == pin.rumor_id)
}
#[cfg(test)]
mod tests {
use nostr::nips::nip44::v2::{self, ConversationKey};
use super::*;
use crate::chat::{ChatRumor, build_delete, build_edit, build_message, open, seal_rumor};
use crate::derive::channel_group_key;
const AT_MS: u64 = 1_700_000_000_000;
const SECRET: [u8; 32] = [0x21u8; 32];
fn channel() -> ChannelId {
ChannelId::from_bytes([0xabu8; 32])
}
fn group() -> GroupKey {
channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives")
}
fn conversation() -> ConversationKey {
*group().conversation()
}
/// A real message through the production seal/open pipeline, as a pinner sees it.
fn sealed_message(author: &Keys, text: &str, at_ms: u64) -> (OpenedStream, ChatRumor) {
let rumor = build_message(
author.public_key(),
&channel(),
Epoch(0),
text,
None,
at_ms,
None,
);
let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals");
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
}
fn entry_for(author: &Keys, text: &str) -> (PinEntry, OpenedStream) {
let (opened, _) = sealed_message(author, text, AT_MS);
let entry = build_entry(&opened, &group(), &channel()).expect("builds");
(entry, opened)
}
fn some(entries: Vec<PinEntry>) -> ReadPinList {
ReadPinList {
entries,
sealed: false,
}
}
/// The load-bearing primitive: the reproduction must open what nostr's own
/// encryption produced, through the disclosure alone.
#[test]
fn a_disclosure_opens_its_message_and_nothing_else() {
let nonce = [0x5au8; 32];
let disclosure =
MessageKeys::derive(conversation().as_bytes().try_into().expect("32"), &nonce)
.expect("derives");
for text in ["a", "hello world", &"padding boundary ".repeat(40)] {
let raw = v2::encrypt_to_bytes_with_nonce(&conversation(), text.as_bytes(), nonce)
.expect("encrypts");
let payload = BASE64.encode(&raw);
assert_eq!(open_payload(&payload, &disclosure).as_deref(), Some(text));
}
// Another nonce discloses different keys, which open nothing else.
let other = v2::encrypt_to_bytes_with_nonce(&conversation(), b"second", [0x99u8; 32])
.expect("encrypts");
assert!(open_payload(&BASE64.encode(&other), &disclosure).is_none());
assert_eq!(
MessageKeys::from_hex(&disclosure.to_hex()),
Some(disclosure)
);
assert!(MessageKeys::from_hex(&disclosure.to_hex().to_uppercase()).is_none());
}
#[test]
fn a_built_entry_proves_its_author_and_cannot_cross_channels() {
let author = Keys::generate();
let (entry, opened) = entry_for(&author, "pin me");
let verified = verify_entry(&entry, &channel()).expect("verifies");
assert_eq!(verified.author, author.public_key());
assert_eq!(verified.content, "pin me");
assert_eq!(verified.rumor_id, opened.rumor_id);
assert_eq!(verified.at_ms, AT_MS);
assert_eq!(verified.epoch, Epoch(0));
// A keyholder must not be able to pin channel X's message into Y's list.
let foreign = ChannelId::from_bytes([0xcdu8; 32]);
assert!(verify_entry(&entry, &foreign).is_none());
// Tampered keys and a re-signed seal both fail.
let mut bad_keys = entry.clone();
bad_keys.keys = format!("00{}", &entry.keys[2..]);
assert!(verify_entry(&bad_keys, &channel()).is_none());
let mut forged = entry.clone();
forged.seal.pubkey = Keys::generate().public_key();
assert!(verify_entry(&forged, &channel()).is_none());
// A rumor carrying a claimed id that is not its own is refused.
let plaintext = stream::open_bytes(&conversation(), &opened.seal.content).expect("opens");
let mut value: serde_json::Value = serde_json::from_slice(&plaintext).expect("json");
value["id"] = serde_json::Value::String("00".repeat(32));
let raw = v2::encrypt_to_bytes_with_nonce(
&conversation(),
value.to_string().as_bytes(),
[0x11u8; 32],
)
.expect("encrypts");
let content = BASE64.encode(&raw);
let seal = EventBuilder::new(Kind::Custom(stream::KIND_SEAL_ENCRYPTED), &content)
.custom_created_at(opened.seal.created_at)
.finalize(&author)
.expect("signs");
let lying = PinEntry {
keys: disclose_keys(&content, conversation().as_bytes().try_into().expect("32"))
.expect("discloses")
.to_hex(),
seal,
wrap: None,
edit: None,
extra: Extra::default(),
};
assert!(verify_entry(&lying, &channel()).is_none());
}
#[test]
fn a_proven_edit_replaces_the_words_and_a_stranger_cannot_revise() {
let author = Keys::generate();
let (entry, original) = entry_for(&author, "teh typo");
let edit = build_edit(
author.public_key(),
&channel(),
Epoch(0),
original.rumor_id,
"the typo, fixed",
AT_MS + 5_000,
None,
);
let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals");
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
let verified = verify_entry(&refreshed, &channel()).expect("verifies");
assert_eq!(verified.content, "the typo, fixed");
assert_eq!(verified.edited.expect("edited").at_ms, AT_MS + 5_000);
// A stranger's edit of the same message never attaches.
let stranger = Keys::generate();
let hijack = build_edit(
stranger.public_key(),
&channel(),
Epoch(0),
original.rumor_id,
"hijacked",
AT_MS + 6_000,
None,
);
let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals");
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
assert!(unchanged.edit.is_none());
}
#[test]
fn both_list_forms_round_trip_and_obey_their_caps() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "hello");
let public =
publishable(&some(vec![entry.clone()]), false, &group(), Epoch(0)).expect("publishes");
let read = read_list(&public, |_| None);
assert!(!read.sealed);
assert_eq!(read.entries.len(), 1);
assert!(verify_entry(&read.entries[0], &channel()).is_some());
// A sealed list stays dark without its key, lights with it, and a wrong
// key reads empty rather than panicking.
let at_epoch_4 = channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives");
let sealed = publishable(&some(vec![entry.clone()]), true, &at_epoch_4, Epoch(4))
.expect("publishes");
let dark = read_list(&sealed, |_| None);
assert!(dark.sealed && dark.entries.is_empty());
let lit = read_list(&sealed, |epoch| {
(epoch == Epoch(4))
.then(|| channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives"))
});
assert!(!lit.sealed);
assert!(verify_entry(&lit.entries[0], &channel()).is_some());
assert!(read_list(&sealed, |_| Some(group())).entries.is_empty());
// 26 entries: the writer refuses, and a hand-built violating edition
// reads as empty rather than forking the chain.
let many = vec![entry; PIN_MAX_ENTRIES + 1];
assert_eq!(
publishable(&some(many.clone()), false, &group(), Epoch(0)),
Err(PinError::TooManyEntries)
);
let violating = serde_json::json!({ "entries": many }).to_string();
assert!(read_list(&violating, |_| None).entries.is_empty());
// Garbage never panics and never reads as a list.
for bad in [
"",
"not json",
"[]",
"42",
r#"{"entries": 7}"#,
r#"{"epoch":"04","sealed":"y"}"#,
] {
let read = read_list(bad, |_| None);
assert!(read.entries.is_empty() && !read.sealed, "{bad}");
}
}
#[test]
fn a_dark_list_is_never_reformed_and_only_the_author_kills_a_pin() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "delete me later");
let dark = ReadPinList {
entries: vec![entry.clone()],
sealed: true,
};
assert_eq!(
publishable(&dark, false, &group(), Epoch(0)),
Err(PinError::Unreadable)
);
let verified = verify_entry(&entry, &channel()).expect("verifies");
for author_keys in [&author, &Keys::generate()] {
let delete = build_delete(
author_keys.public_key(),
&channel(),
Epoch(0),
verified.rumor_id,
Some(KIND_MESSAGE),
None,
AT_MS + 1_000,
);
let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals");
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
assert_eq!(
killed_by(&verified, &rumor),
author_keys.public_key() == author.public_key()
);
}
}
}
+4 -38
View File
@@ -21,12 +21,7 @@ use crate::stream::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamErr
use crate::{ChannelId, CommunityId, Epoch, GroupKey, random_32};
pub const KIND_REKEY: u16 = 3303;
/// The send cap. A rekey rides the CORD-01 double envelope, so each blob costs two
/// NIP-44 base64 expansions: 120 blobs measure ~77 KB and a 64 KB relay refuses
/// them, while 80 measure ~55 KB. CORD-06 states 120 — an erratum this reproduces.
pub const MAX_REKEY_BLOBS_PER_EVENT: usize = 80;
/// The accept cap stays at the spec's 120, above the send cap, so a chunk minted by
/// another client at the spec limit still parses.
pub const MAX_REKEY_BLOBS_RECEIVED: usize = 120;
pub const MAX_REKEY_EPOCH: u64 = 1 << 40;
@@ -115,8 +110,7 @@ pub enum RekeyScope {
}
impl RekeyScope {
/// The all-zero sentinel addresses the base; a channel id is random, so it
/// never collides. The value is stamped inside every blob's ciphertext.
/// The all-zero sentinel addresses the base; a channel id is random.
pub fn id32(self) -> [u8; 32] {
match self {
RekeyScope::Channel(channel) => *channel.as_bytes(),
@@ -259,9 +253,6 @@ pub fn parse_blob_plaintext(
return Err(RekeyError::ControlPairMismatch);
}
// A width past 136 is a form this client predates. Refusing it would park
// the member at the old epoch, so the frozen prefix and the appended fields
// that still verify are kept and the rest freezes.
return Ok(KeyDelivery {
new_key,
control_pk: Some(control_pk),
@@ -276,9 +267,7 @@ pub fn parse_blob_plaintext(
})
}
/// The rekey plane's address for a scope. A standalone channel rotation rides the
/// current root; one forced by a removal rides the prior root beside the base
/// rotation, which is exactly what lets a base-fork loser still open it.
/// The rekey plane's address for a scope.
pub fn rekey_group(
scope: RekeyScope,
addressing_root: &[u8; 32],
@@ -324,9 +313,6 @@ pub fn build_blob(
})
}
/// The locator is public and authenticates nothing, so it is not gated here:
/// the pairwise decrypt, plus the scope and epoch bound inside the ciphertext,
/// are the whole gate.
pub fn open_blob(
recipient: &Keys,
rotator: &PublicKey,
@@ -342,8 +328,6 @@ pub fn open_blob(
parse_blob_plaintext(&plaintext, scope, epoch, community_id)
}
/// Every blob at my locator. Anyone can publish a blob at mine, since the locator
/// is public, so the caller tries each and adopts the first that opens.
pub fn find_my_blobs<'a>(
blobs: &'a [RekeyBlob],
rotator: &PublicKey,
@@ -352,7 +336,6 @@ pub fn find_my_blobs<'a>(
epoch: Epoch,
) -> impl Iterator<Item = &'a RekeyBlob> {
let wanted = blob_locator(rotator, me, scope, epoch);
blobs.iter().filter(move |blob| blob.locator == wanted)
}
@@ -362,7 +345,6 @@ fn seal_to(
plaintext: &[u8],
) -> Result<String, RekeyError> {
let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?;
Ok(stream::seal_bytes(&conversation, plaintext)?)
}
@@ -379,8 +361,7 @@ pub struct RekeyChunk {
pub severed: bool,
}
/// The key that groups the chunks of one rotation. Two rotators racing the same
/// epoch, or one rotator over two channels, never alias.
/// The key that groups the chunks of one rotation.
pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
impl RekeyChunk {
@@ -404,8 +385,6 @@ pub struct Rotation {
pub blobs: Vec<RekeyBlob>,
pub declared: u32,
pub held: BTreeSet<u32>,
/// OR across chunks: an extension minted without the marker must not launder
/// a severed rotation back into an ordinary one.
pub severed: bool,
pub citation: Option<AuthorityCitation>,
}
@@ -450,8 +429,6 @@ pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
rotation.severed |= chunk.severed;
rotation.held.insert(chunk.chunk.0);
// A union, never first-wins: two chunks can claim one index after a
// catch-up, and a recipient dropped from the union reads as removed.
for blob in &chunk.blobs {
if !rotation.blobs.iter().any(|held| held == blob) {
rotation.blobs.push(blob.clone());
@@ -502,9 +479,6 @@ fn continuity(
}
}
/// The winner among concurrent rotations at one continuity point: the lowest key,
/// adopted only when it strictly lowers a key already held. A settled epoch heals
/// down and never re-forks upward.
pub fn fork_winner(held: Option<&[u8; 32]>, candidates: &[[u8; 32]]) -> Option<usize> {
let (index, winner) = candidates.iter().enumerate().min_by_key(|(_, key)| **key)?;
@@ -514,8 +488,6 @@ pub fn fork_winner(held: Option<&[u8; 32]>, candidates: &[[u8; 32]]) -> Option<u
}
}
/// Holding a key is never authority, so a rotation is honored only from an actor
/// with the permission who strictly outranks every target it removes.
pub fn rekey_authorized(
roles: &CommunityRoles,
owner: &PublicKey,
@@ -554,9 +526,7 @@ pub fn plan_refounding(epoch: Epoch) -> Result<Refounding> {
})
}
/// Carries the settled heads across a refounding. The control plane is
/// plaintext-sealed precisely so this preserves the original authors' signatures
/// instead of re-signing a snapshot as the refounder.
/// Carries the settled heads across a refounding.
pub fn compact(
seals: &[Event],
read: &GroupKey,
@@ -742,10 +712,6 @@ pub struct DissolvedTombstone {
pub owner: PublicKey,
}
/// The `eid` commits the community, deliberately diverging from the all-zero
/// placeholder CORD-02 §9 shows: the dissolved address derives from the public
/// `community_id`, so a zero binding lets an owner's genuine tombstone for one of
/// their communities be re-wrapped at another and kill it.
pub fn dissolved_tombstone_rumor(
owner: PublicKey,
community_id: &CommunityId,
+5 -1
View File
@@ -1,6 +1,6 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
use anyhow::Result;
use anyhow::{Result, bail};
use nostr_sdk::prelude::PublicKey;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -127,6 +127,10 @@ impl Grant {
}
pub fn to_content(&self) -> Result<String> {
if self.role_ids.len() > MAX_ROLES_PER_MEMBER {
bail!("grant exceeds {MAX_ROLES_PER_MEMBER} roles");
}
Ok(serde_json::to_string(self)?)
}
}
+145 -5
View File
@@ -25,11 +25,18 @@ const WRAP_TAG: &str = "e";
const KIND_TAG: &str = "k";
const STATE_PREFIX: &str = "concord/";
/// CORD-08 §3: an already-expired rumor is refused at ingest, never stored.
/// Returns whether the rumor was kept.
pub async fn cache_rumor(
database: &dyn NostrDatabase,
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<()> {
) -> Result<bool> {
if chat::expiration_of(&opened.rumor)?.is_some_and(|expiration| expiration <= Timestamp::now())
{
return Ok(false);
}
let tags = vec![
Tag::identifier(opened.rumor_id),
Tag::custom(KIND_TAG, [opened.rumor.kind.to_string()]),
@@ -47,7 +54,42 @@ pub async fn cache_rumor(
database.save_event(&event).await?;
Ok(())
Ok(true)
}
pub async fn purge_expired(
database: &dyn NostrDatabase,
channel: &ChannelId,
now: Timestamp,
) -> Result<usize> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
let mut expired = Vec::new();
for event in database.query(filter).await? {
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
continue;
};
let Ok(Some(expiration)) = chat::expiration_of(&rumor) else {
continue;
};
if expiration <= now {
expired.push(event.id);
}
}
let purged = expired.len();
if purged > 0 {
database.delete(Filter::new().ids(expired)).await?;
}
Ok(purged)
}
pub async fn query_rumors(
@@ -300,8 +342,9 @@ pub async fn backfill(
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
for (opened, rumor) in fresh {
cache_rumor(database, channel, &opened).await?;
found.push(rumor);
if cache_rumor(database, channel, &opened).await? {
found.push(rumor);
}
}
match next {
@@ -420,7 +463,15 @@ mod tests {
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
] {
let group = channel_group_key(secret, &channel, epoch).expect("derives");
let rumor = build_message(author.public_key(), &channel, epoch, content, None, at_ms);
let rumor = build_message(
author.public_key(),
&channel,
epoch,
content,
None,
at_ms,
None,
);
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
}
@@ -505,4 +556,93 @@ mod tests {
assert_eq!(capped[0].content, "second");
});
}
#[test]
fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0x77u8; 32]);
let author = Keys::generate();
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
let now = Timestamp::now().as_secs();
smol::block_on(async {
// A live timer is stored; one that already elapsed is refused at ingest.
assert!(
cache(
&database,
&group,
&channel,
&author,
"live",
Some(3_600),
now
)
.await
);
assert!(
!cache(
&database,
&group,
&channel,
&author,
"gone",
Some(1),
now - 120
)
.await
);
let stored = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].content, "live");
// Hiding is not disappearing: the sweep removes the row itself,
// judged on the rumor's own signed tag.
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 7_200))
.await
.expect("sweeps");
assert_eq!(purged, 1);
assert!(
query_rumors(&database, &channel, None, 10)
.await
.expect("queries")
.is_empty()
);
// An untimed rumor is never swept, whatever the clock says.
assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await);
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400))
.await
.expect("sweeps");
assert_eq!(purged, 0);
});
}
async fn cache(
database: &MemoryDatabase,
group: &GroupKey,
channel: &ChannelId,
author: &Keys,
content: &str,
timer: Option<u64>,
at_secs: u64,
) -> bool {
let rumor = build_message(
author.public_key(),
channel,
Epoch(0),
content,
None,
at_secs * 1_000,
timer,
);
let (wrap, _) = seal_rumor(&rumor, group, author, false).expect("seals");
let opened = open_wrap(&wrap, group).expect("opens");
cache_rumor(database, channel, &opened)
.await
.expect("caches")
}
}
+1
View File
@@ -185,6 +185,7 @@ pub fn seal_content(
}
pub fn seal_bytes(conversation: &ConversationKey, plaintext: &[u8]) -> Result<String, StreamError> {
check_plaintext_cap(plaintext.len())?;
Ok(BASE64.encode(&encrypt(conversation, plaintext)?))
}