add rekeys, refounding and dissolution

This commit is contained in:
2026-09-17 08:16:41 +07:00
parent 79a4dd387d
commit ecd08273eb
8 changed files with 2035 additions and 44 deletions
+136 -23
View File
@@ -556,7 +556,7 @@ pub struct ChatMessage {
gpui type, and a protocol crate does not take a UI dependency for a derived field. They gpui type, and a protocol crate does not take a UI dependency for a derived field. They
land with the first consumer that renders them. land with the first consumer that renders them.
### 8.5 Invites (`invite.rs`) — implemented in M6 ### 8.5 Invites (`invite.rs`) — bundle and Direct Invite in M6, Invite List in M7, Registry in M7
```rust ```rust
pub const KIND_BUNDLE: u16 = 33301; pub const KIND_BUNDLE: u16 = 33301;
@@ -635,11 +635,41 @@ as a NIP-40 tag in seconds.
- The earlier sketch's `signer: &UniversalSigner` is `&Keys`, matching every other builder in the - The earlier sketch's `signer: &UniversalSigner` is `&Keys`, matching every other builder in the
crate: NIP-46 is one deliberate pass, not a per-milestone patch (see §14.9). crate: NIP-46 is one deliberate pass, not a per-milestone patch (see §14.9).
**The Invite List (13303) and the Registry (vsk 8) are deferred to M7**, together and for the same **The Invite List (`13303`) and the Registry (`vsk 8`) landed in M7**, deferred from M6 for one reason:
reason: nothing in M6 consumes them. A link's signer is held by its caller, so minting, refreshing nothing in M6 consumed them. A link's signer is held by its caller, so minting, refreshing and revoking
and revoking need no document, and a Registry write whose fold does not exist is dead wire. M7 is need no document, and a Registry write whose fold does not exist is dead wire.
where both become load-bearing — the Registry's aggregate is the Public/Private source of truth and
retiring the last live link is what triggers a Refounding. ```rust
pub const KIND_INVITE_LIST: u16 = 13303;
pub const MAX_INVITE_ENTRIES: usize = 64;
pub struct InviteEntry { token: String, signer_sk: String, community_id: CommunityId, url: String,
label: Option<String>, created_at: u64, expires_at: Option<u64>, extra }
pub struct InviteTombstone { token: String, community_id: CommunityId, extra }
pub struct InviteList { entries: Vec<InviteEntry>, tombstones: Vec<InviteTombstone>, extra }
impl InviteList {
pub fn is_live(&self, token: &str) -> bool;
pub fn fits(&self) -> Result<(), InviteError>; // the write gate
}
pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList;
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError>;
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError>;
```
- The creator's private bookkeeping: `token` is the link's unlock secret **and** its merge key, and
`signer_sk` is the `link_signer` secret that refreshing or retiring the bundle needs. An entry is
immutable once minted, so a divergent pair is settled on the lowest canonical bytes — a total order, so
two devices never flap. Tombstones union and beat an entry **terminally**, so a stale device can never
resurrect a revoked link. Like the Community List it is NIP-44-to-self at a replaceable kind, and
`fits` refuses to build past the entry cap or the NIP-44 plaintext cap.
- The Registry is its member-facing shadow: a Control Plane entity (`vsk 8`) at
`invite_links_locator(community_id, creator)` whose content is the live links' **coordinates only**
never a token, URL or signing secret — so members can see that links exist without being able to use one.
`ControlFold.registries` holds one set per creator, honored only under `CREATE_INVITE`, and
`ControlFold::is_public` reads their aggregate: non-empty means a live link exists and the community is
Public. Retiring the last live link empties it, and that flip is what a Refounding seals (§8.7).
`ControlWriter::set_registry` is the write side.
### 8.6 The Community List (`list.rs`) — implemented in M6 ### 8.6 The Community List (`list.rs`) — implemented in M6
@@ -691,26 +721,103 @@ membership is the registry's, and the byte-level cap audit is M8's.
holds one event per pubkey, so it cannot shard past the size cap); that remains an interop holds one event per pubkey, so it cannot shard past the size cap); that remains an interop
follow-up, recorded in §14.1. follow-up, recorded in §14.1.
### 8.7 Rekeys and refoundings (`rekey.rs`) ### 8.7 Rekeys, refoundings and dissolution (`rekey.rs`) — implemented in M7
```rust ```rust
pub const KIND_REKEY: u16 = 3303;
pub const MAX_REKEY_BLOBS_PER_EVENT: usize = 80;
pub const MAX_REKEY_BLOBS_RECEIVED: usize = 120;
pub const MAX_REKEY_EPOCH: u64 = 1 << 40;
pub enum RekeyScope { Channel(ChannelId), Base } pub enum RekeyScope { Channel(ChannelId), Base }
pub fn encode_blob_plaintext(scope, epoch, new_root, control_pk, control_root) -> Vec<u8>; // 72 | 104 | 136 bytes pub struct RekeyBlob { locator: String, wrapped: String }
pub fn parse_blob_plaintext(bytes: &[u8], scope, epoch) -> Result<KeyDelivery, RekeyError>; pub struct KeyDelivery { new_key: [u8; 32], control_pk: Option<[u8; 32]>, control_root: Option<[u8; 32]> }
pub fn build_rekey_rumor(rotator, scope, new_epoch, prev_epoch, prev_commit, blobs, chunk) -> UnsignedEvent; pub enum Continuity { Extends, Gap, Fork }
pub fn plan_refounding(fold, removed: &[PublicKey]) -> Result<Refounding, RekeyError>; pub struct RekeyChunk { rotator, scope, new_epoch, prev_epoch, prev_commit, chunk: (u32, u32),
pub fn compact(fold, epoch, new_control_root, ...) -> Vec<Event>; // re-wrap heads verbatim, plaintext seals preserved blobs, citation, severed }
pub struct Rotation { rotator, scope, new_epoch, prev_epoch, prev_commit, blobs, declared,
held, severed, citation }
pub struct Refounding { epoch: Epoch, new_root: [u8; 32], new_control_root: [u8; 32] }
pub fn encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root) -> Result<Vec<u8>, RekeyError>;
pub fn parse_blob_plaintext(bytes, scope, epoch, community_id) -> Result<KeyDelivery, RekeyError>;
pub fn rekey_group(scope, addressing_root, community_id, new_epoch) -> Result<GroupKey>;
pub fn blob_locator(rotator, recipient, scope, epoch) -> String;
pub fn build_blob(rotator: &Keys, recipient, scope, epoch, new_key, control_pk, control_root)
-> Result<RekeyBlob, RekeyError>;
pub fn open_blob(recipient: &Keys, rotator, scope, epoch, blob, community_id)
-> Result<KeyDelivery, RekeyError>;
pub fn find_my_blobs<'a>(blobs: &'a [RekeyBlob], rotator, me, scope, epoch)
-> impl Iterator<Item = &'a RekeyBlob>;
pub fn build_rekey_rumor(rotator, scope, new_epoch, prev_epoch, prev_commit, blobs, chunk,
citation, severed, at_secs) -> Result<UnsignedEvent, RekeyError>;
pub fn build_rekey_chunks(rotator: &Keys, group, scope, new_epoch, prev_epoch, prev_commit, blobs,
citation, severed, at_secs) -> Result<Vec<Event>, RekeyError>;
pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError>;
pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation>;
pub fn am_i_removed(rotation: &Rotation, me: &PublicKey) -> Option<bool>;
pub fn rekey_authorized(roles, owner, rotator, permission, removed) -> bool;
pub fn fork_winner(held: Option<&[u8; 32]>, candidates: &[[u8; 32]]) -> Option<usize>;
pub fn plan_refounding(epoch: Epoch) -> Result<Refounding>;
pub fn compact(seals: &[Event], read: &GroupKey, signer: &GroupKey, at_secs: u64)
-> Result<Vec<Event>, RekeyError>;
pub struct DissolvedTombstone { owner: PublicKey }
pub fn dissolved_tombstone_rumor(owner, community_id, at_secs) -> UnsignedEvent;
pub fn seal_dissolved(rumor, community_id, owner: &Keys, at_secs) -> Result<Event, RekeyError>;
pub fn open_dissolved(wrap, community_id) -> Result<DissolvedTombstone, RekeyError>;
pub fn verify_dissolved(wrap, identity: &CommunityIdentity) -> bool;
``` ```
- The subscription for rekeys is precomputed from the *next* epoch's address, per private channel and once for the base. - The subscription for rekeys is precomputed from the *next* epoch's address, per private channel
- A receiver accepts a key only after: locating its blob, decrypting with the rotator↔recipient conversation key, checking the bound `scope` and `epoch` inside the plaintext, and matching `prevcommit` against the key it currently holds. and once for the base. That is a `Filter` and belongs to the sync engine (§10), not here; `rekey_group`
- Only after holding **all** `n` chunks of one `(rotator, newepoch, prevcommit)` set, with none containing its locator, may a client conclude it was removed. is what makes it derivable, and keeps a channel scope from being addressed at the base derivation.
- Send cap 80 blobs per event, accept cap 120 (Vector's documented erratum: the CORD-01 double envelope pushes 120 blobs past a 64 KB relay limit). Record the reason in a comment so nobody "fixes" it back. - A receiver accepts a key only after: locating its blob, decrypting with the rotator↔recipient
- Compacted control heads are re-wrapped with their original signature intact, which is exactly why the control plane uses the plaintext seal. conversation key, checking the bound `scope` and `epoch` **inside** the plaintext, and matching
- Two concurrent refoundings converge on the lexicographically lowest new base key; the heal is down-only. `prevcommit` against the key it currently holds. The locator is deliberately *not* gated on open:
- Authority: a channel rekey needs `MANAGE_CHANNELS`, a refounding needs `BAN`, and in both the rotator must strictly outrank every removed target. Holding a key is never authority. it derives from public keys alone (NIP-46 bunker parity) and so proves nothing, and the pairwise
decrypt plus the bound check are the whole gate.
- Only after holding **all** `n` chunks of one `(rotator, newepoch, prevcommit)` set, with none
containing its locator, may a client conclude it was removed. `collect_rotations` **unions** the blobs
of two chunks claiming one index rather than keeping the first: a catch-up chunk can legitimately
re-claim a slot, and a recipient dropped from the union would read as removed, which deletes the
community locally. `severed` is an OR across chunks for the same reason in reverse.
- Send cap 80 blobs per event, accept cap 120. The 80 is an erratum this reproduces: the CORD-01 double
envelope costs two NIP-44 base64 expansions, so 120 blobs measure ~77 KB and a 64 KB relay refuses
them. The accept cap stays at the spec's 120 so a peer at the spec limit still parses.
- `parse_blob_plaintext` takes the `community_id`, which the earlier sketch did not: the 104/136-byte
base forms carry the next epoch's Control Plane keys, and the 136-byte secret must derive to the
`control_pk` beside it — a community- and epoch-bound check. A **width past 136** is a form this client
predates, so it degrades rather than refusing: the frozen 72-byte prefix yields the root (membership and
every chat plane survive), the appended pair is kept when it verifies, and the rest freezes. Widths
between the defined forms fit no extension and stay malformed.
- Authority: a channel rekey needs `MANAGE_CHANNELS`, a refounding needs `BAN`, and in both the rotator
must strictly outrank every removed target. `rekey_authorized` is that whole rule — one permission check
plus `can_act_on_member` per target — so it is testable without minting a rotation, and an empty removed
set (a hygienic rotation, or a flip to Private) needs only the permission. Holding a key is never authority.
- Two concurrent refoundings converge on the lexicographically lowest new base key, and the heal is
down-only. `fork_winner` folds both into one call: the lowest candidate, returned only when it strictly
lowers a key already held, so a flaky fetch cannot re-fork a settled epoch.
- `Refounding` owns the epoch and the freshly minted pair and derives all three coordinates from them
(`read`, `signer`, and the signer's pk), so a caller cannot pair a root with the wrong epoch.
`plan_refounding` deliberately takes neither the fold nor the removed set: the authority gate is
`rekey_authorized`, one job each, and the pair travels in the base blobs.
- Compacted control heads are re-wrapped with their original signature intact, which is exactly why the
control plane uses the plaintext seal. `compact` is `stream::rewrap_seal` over the held seals, whose
signature grew a separate `signer` group in M7 — a split epoch reads under the rolled root but wraps as
the new control signer, so one group could not express it.
- Nothing in coop **mints** `severed` yet: it is a receiver-side rule, and the Server-Severing flow that
would set it awaits the invite-link wiring of §10.
Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at `dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the community is sealed read-only: subscriptions halt, nothing new is honored, existing history stays readable, and a member's delete of their own message is still honored. Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at
`dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is
not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine
tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the
community is sealed read-only: `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.
## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3, banlist in M5 ## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3, banlist in M5
@@ -761,7 +868,7 @@ pub struct CommunityState {
} }
``` ```
Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), `dissolved` (needs the tombstone, M7), and `observed`/`guestbook` (need the guestbook ingest of §10). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key. `banned` is a `BTreeSet` — serde has no such problem with a `Vec`-like sequence. Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. `dissolved` landed in M7, set from a verified tombstone. Two fields the plan sketched are still absent: `epoch_keys`, because `ChannelKeyRef` carries no key for it to hold (§14.11), and `observed`/`guestbook`, which need the guestbook ingest of §10. `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key. `banned` is a `BTreeSet` — serde has no such problem with a `Vec`-like sequence.
M3 added the two bridges between this document and the fold: M3 added the two bridges between this document and the fold:
@@ -929,6 +1036,7 @@ Each of these has burned a real implementation, or is a documented cross-client
- **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 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 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 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. - Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, and the write-side counterparts of the caps the folds already apply.
## 13. Milestones ## 13. Milestones
@@ -942,7 +1050,7 @@ Each of these has burned a real implementation, or is a documented cross-client
| M4 | Chat plane | ✅ `cargo test -p concord` (19 tests): a second holder folds a message's reactions, its author's edit and its author's delete, and ignores an edit or a delete from anybody else; a comment's root and parent survive the wire; a foreign channel, a replayed epoch, a plaintext seal, a retired kind and a duplicated target are each rejected; and history pages backwards across a rekey in order | | M4 | Chat plane | ✅ `cargo test -p concord` (19 tests): a second holder folds a message's reactions, its author's edit and its author's delete, and ignores an edit or a delete from anybody else; a comment's root and parent survive the wire; a foreign channel, a replayed epoch, a plaintext seal, a retired kind and a duplicated target are each rejected; and history pages backwards across a rekey in order |
| M5 | Guestbook + member list + moderation | ✅ `cargo test -p concord` (23 tests): a second holder folds joins, leaves, a cited kick and a chunked snapshot into one memberlist in either arrival order, with a ban and a Grant deciding the edges; an uncited, unranked or owner-directed kick and a foreign snapshot are dropped; a future-dated entry, a malformed `ms`, a non-verb `3306`, a duplicated `vac` and a bad snapshot chunk are each refused; and a moderator delete lands only under a citation the roster admits, while a self-delete never asks | | 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 | | M6 | Invites + Community List | ✅ `cargo test -p concord` (32 tests): the fragment's byte layout is pinned by golden base64url for the stock set, a dictionary mix and a verbatim literal, with a wrong version in either direction, trailing bytes and an over-cap count each fatal; a link round-trips as a full URL and as a bare naddr, and refuses a non-invite; a bundle round-trips while a revocation tombstone reads as revoked, and a wrong token, a squatter's author, a foreign `d`, a forged owner, a malformed secret and an over-cap channel count are each refused; a Direct Invite round-trips to its verified inviter and refuses a stranger's keys and a non-invite rumor; and the Community List keeps the earlier seed and the later current in either merge order, refuses to resurrect a tombstoned id until a newer join outruns it, and rebuilds on a second device with unknown fields intact while refusing an over-cap or oversized list |
| M7 | Rekeys + refounding + dissolution | a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused | | 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 | 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 |
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. 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.
@@ -965,7 +1073,11 @@ What M5 still defers, and to what: the **guestbook's fetch and ingest path** —
M6 closed at 32 tests, again with no dependency change and `Cargo.lock` untouched — the fragment codec is `data_encoding::BASE64URL_NOPAD` and the Direct Invite is nostr's own `nip59` builder and unwrapper, so nothing new was needed. New: `src/invite.rs` (bundle, link, fragment, Direct Invite) and `src/list.rs` (the Community List). In `stream.rs`, the seal and open primitives were factored out of `seal_content`/`decode_content` as `seal_bytes`/`open_bytes`, so the bundle's raw-token key and the List's to-self envelope share the crate's one NIP-44 shape instead of re-implementing it twice; `edition::TAG_SUBKIND` became public so the invite sub-kind tags do not restate the string. M6 closed at 32 tests, again with no dependency change and `Cargo.lock` untouched — the fragment codec is `data_encoding::BASE64URL_NOPAD` and the Direct Invite is nostr's own `nip59` builder and unwrapper, so nothing new was needed. New: `src/invite.rs` (bundle, link, fragment, Direct Invite) and `src/list.rs` (the Community List). In `stream.rs`, the seal and open primitives were factored out of `seal_content`/`decode_content` as `seal_bytes`/`open_bytes`, so the bundle's raw-token key and the List's to-self envelope share the crate's one NIP-44 shape instead of re-implementing it twice; `edition::TAG_SUBKIND` became public so the invite sub-kind tags do not restate the string.
What M6 still defers, and to what: the **Invite List (13303) and the Registry (`vsk 8`)**, both to M7 and for the same reason — nothing in M6 consumes them, and a Registry write whose fold does not exist is dead wire, while M7's refounding is what reads the Registry's aggregate as the Public/Private source of truth (see §8.5); the **join gate that refuses a 51st membership** (the registry's, since `fits` protects the write rather than the add); and the byte-level cap audit, which is M8's. What M6 still defers, and to what: the **Invite List (13303) and the Registry (`vsk 8`)** both landed in M7, as planned; the **join gate that refuses a 51st membership** (the registry's, since `fits` protects the write rather than the add); and the byte-level cap audit, which is M8's.
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.
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.
**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. **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.
@@ -981,6 +1093,7 @@ What M6 still defers, and to what: the **Invite List (13303) and the Registry (`
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 has no public API (M8).** CORD-04 §7 Pins let a keyless reader verify a disclosure, which means revealing one message's NIP-44 keys rather than the plane's conversation key. `nostr`'s `nip44::v2::get_message_keys(conversation_key, nonce)` is a private `fn`, and both public entry points (`encrypt_to_bytes_with_nonce`, `decrypt_to_bytes`) take the whole conversation key — so the expansion has to be reproduced as `hkdf::expand_into(conversation_key, nonce, 76 bytes)` plus ChaCha20 and an HMAC-SHA256, exactly as Vector does, and round-tripped against nostr's own `encrypt` in a test. Read CORD-04 §7 in full at M8 before writing it: the reproduction is only worth it once the exact verification the pin must support is settled, and the alternative is contributing a `pub` message-key accessor upstream (we already track git master, so a patch branch or an upstream PR is viable and strictly better than a reproduction we must keep in sync).
9. **A remote signer is not plumbed.** `ControlWriter::publish`, `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. 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. 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.
## 15. Test strategy ## 15. Test strategy
+89 -1
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::derive::{ use crate::derive::{
banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator, banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator,
verify_community_id, invite_links_locator, verify_community_id,
}; };
use crate::edition::{ use crate::edition::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition, AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
@@ -21,6 +21,7 @@ use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
pub const MAX_NAME_BYTES: usize = 64; pub const MAX_NAME_BYTES: usize = 64;
pub const MAX_DESCRIPTION_BYTES: usize = 10_000; pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
pub const MAX_RELAYS: usize = 5; pub const MAX_RELAYS: usize = 5;
pub const MAX_REGISTRY_LINKS: usize = 64;
pub const GENERAL_CHANNEL: &str = "general"; pub const GENERAL_CHANNEL: &str = "general";
pub const ROOT_EPOCH: Epoch = Epoch(0); pub const ROOT_EPOCH: Epoch = Epoch(0);
@@ -334,6 +335,37 @@ impl ControlWriter {
at_secs, at_secs,
) )
} }
#[allow(clippy::too_many_arguments)]
pub fn set_registry(
&self,
keys: &Keys,
community_id: &CommunityId,
creator: &PublicKey,
links: &[PublicKey],
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let entries: Vec<String> = links
.iter()
.take(MAX_REGISTRY_LINKS)
.map(PublicKey::to_hex)
.collect();
let content = serde_json::to_string(&entries)?;
self.publish(
keys,
Edition {
subkind: vsk::INVITE_LINKS,
entity: invite_links_locator(community_id, &creator.to_bytes()),
content: &content,
head,
citation,
},
at_secs,
)
}
} }
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> { fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
@@ -361,10 +393,18 @@ pub struct ControlFold {
pub banned: BTreeSet<PublicKey>, pub banned: BTreeSet<PublicKey>,
pub community: Option<CommunityMetadata>, pub community: Option<CommunityMetadata>,
pub channels: BTreeMap<ChannelId, ChannelMetadata>, pub channels: BTreeMap<ChannelId, ChannelMetadata>,
/// Each creator's live link-signer set.
pub registries: BTreeMap<PublicKey, Vec<PublicKey>>,
pub floors: Floors, pub floors: Floors,
pub gapped: bool, pub gapped: bool,
} }
impl ControlFold {
pub fn is_public(&self) -> bool {
self.registries.values().any(|links| !links.is_empty())
}
}
pub fn fold_control( pub fn fold_control(
owner: &PublicKey, owner: &PublicKey,
community_id: &CommunityId, community_id: &CommunityId,
@@ -388,6 +428,7 @@ pub fn fold_control(
banned: roster.banned, banned: roster.banned,
community: metadata.community, community: metadata.community,
channels: metadata.channels, channels: metadata.channels,
registries: metadata.registries,
floors, floors,
gapped: roster.gapped || metadata.gapped, gapped: roster.gapped || metadata.gapped,
} }
@@ -397,6 +438,7 @@ pub fn fold_control(
struct MetadataFold { struct MetadataFold {
community: Option<CommunityMetadata>, community: Option<CommunityMetadata>,
channels: BTreeMap<ChannelId, ChannelMetadata>, channels: BTreeMap<ChannelId, ChannelMetadata>,
registries: BTreeMap<PublicKey, Vec<PublicKey>>,
floors: Floors, floors: Floors,
gapped: bool, gapped: bool,
} }
@@ -464,9 +506,55 @@ fn fold_metadata(
} }
} }
fold.registries = fold_registries(&judge, editions, &mut fold.floors, &mut fold.gapped);
fold fold
} }
fn fold_registries(
judge: &Judge<'_>,
editions: &[ParsedEdition],
floors: &mut Floors,
gapped: &mut bool,
) -> BTreeMap<PublicKey, Vec<PublicKey>> {
let mut candidates: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for edition in editions {
if edition.subkind == vsk::INVITE_LINKS
&& invite_links_locator(judge.community_id, &edition.author.to_bytes())
== edition.entity
{
candidates.entry(edition.entity).or_default().push(edition);
}
}
let mut registries = BTreeMap::new();
for (entity, group) in &candidates {
let Some(head) = authorized_head(judge, *entity, group, Permissions::CREATE_INVITE, gapped)
else {
continue;
};
floors.insert(*entity, EntityHead::from(head));
let Ok(links) = serde_json::from_str::<Vec<String>>(&head.content) else {
continue;
};
registries.insert(
head.author,
links
.iter()
.filter_map(|link| PublicKey::from_hex(link).ok())
.take(MAX_REGISTRY_LINKS)
.collect(),
);
}
registries
}
struct Judge<'a> { struct Judge<'a> {
owner: &'a PublicKey, owner: &'a PublicKey,
community_id: &'a CommunityId, community_id: &'a CommunityId,
+163 -11
View File
@@ -1,9 +1,12 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::fmt; use std::fmt;
use data_encoding::BASE64URL_NOPAD; use data_encoding::BASE64URL_NOPAD;
use nostr::nips::nip01::Coordinate; use nostr::nips::nip01::Coordinate;
use nostr::nips::nip19::{Nip19, Nip19Coordinate}; use nostr::nips::nip19::{Nip19, Nip19Coordinate};
use nostr::nips::nip44::v2::ConversationKey; use nostr::nips::nip44::v2::ConversationKey;
use nostr::nips::nip44::{self, Version};
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift}; use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -11,15 +14,18 @@ use serde::{Deserialize, Serialize};
use crate::control::{ImageRef, MAX_RELAYS}; use crate::control::{ImageRef, MAX_RELAYS};
use crate::derive::{TOKEN_LEN, verify_community_id}; use crate::derive::{TOKEN_LEN, verify_community_id};
use crate::edition::{TAG_SUBKIND, vsk}; use crate::edition::{TAG_SUBKIND, vsk};
use crate::stream::{self, StreamError}; use crate::list::{canonical, union};
use crate::stream::{self, NIP44_MAX_PLAINTEXT, StreamError};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32}; use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_BUNDLE: u16 = 33301; pub const KIND_BUNDLE: u16 = 33301;
pub const KIND_INVITE_LIST: u16 = 13303;
pub const KIND_DIRECT_INVITE: u16 = 3313; pub const KIND_DIRECT_INVITE: u16 = 3313;
pub const FRAGMENT_VERSION: u8 = 4; pub const FRAGMENT_VERSION: u8 = 4;
pub const MAX_BUNDLE_CHANNELS: usize = 256; pub const MAX_BUNDLE_CHANNELS: usize = 256;
pub const MAX_BOOTSTRAP_RELAYS: usize = 3; pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40; pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
pub const MAX_INVITE_ENTRIES: usize = 64;
const FLAG_STOCK_SET: u8 = 0x01; const FLAG_STOCK_SET: u8 = 0x01;
const INVITE_PATH: &str = "/invite/"; const INVITE_PATH: &str = "/invite/";
@@ -39,6 +45,9 @@ pub enum InviteError {
Json(String), Json(String),
BadHex(&'static str), BadHex(&'static str),
TooManyChannels(usize), TooManyChannels(usize),
TooManyInvites(usize),
Oversize(usize),
Kind(u16),
EpochTooLarge(u64), EpochTooLarge(u64),
OwnerMismatch, OwnerMismatch,
BadFragment(&'static str), BadFragment(&'static str),
@@ -60,6 +69,16 @@ impl fmt::Display for InviteError {
"bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})" "bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})"
) )
} }
InviteError::TooManyInvites(count) => {
write!(
f,
"invite list carries {count} entries (cap {MAX_INVITE_ENTRIES})"
)
}
InviteError::Oversize(len) => {
write!(f, "invite list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
}
InviteError::Kind(kind) => write!(f, "not an invite list kind: {kind}"),
InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"), InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"),
InviteError::OwnerMismatch => { InviteError::OwnerMismatch => {
write!(f, "bundle owner does not reproduce its community_id") write!(f, "bundle owner does not reproduce its community_id")
@@ -123,7 +142,6 @@ pub struct CommunityInvite {
} }
impl CommunityInvite { impl CommunityInvite {
/// Parse, bound and validate a decrypted bundle, whichever lane carried it.
pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> { pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
let mut invite: Self = let mut invite: Self =
serde_json::from_str(json).map_err(|error| InviteError::Json(error.to_string()))?; serde_json::from_str(json).map_err(|error| InviteError::Json(error.to_string()))?;
@@ -472,6 +490,149 @@ pub fn unwrap_direct_invite(
)) ))
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteEntry {
/// The link's unlock secret, and its merge key.
pub token: String,
/// The `link_signer` secret: refreshing or retiring the bundle needs it.
pub signer_sk: String,
pub community_id: CommunityId,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteTombstone {
pub token: String,
pub community_id: CommunityId,
#[serde(flatten)]
pub extra: Extra,
}
/// A creator's own link bookkeeping.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct InviteList {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<InviteEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<InviteTombstone>,
#[serde(flatten)]
pub extra: Extra,
}
impl InviteList {
/// A tombstone beats an entry terminally, so a stale device can never resurrect a revoked link.
pub fn is_live(&self, token: &str) -> bool {
self.entries.iter().any(|entry| entry.token == token)
&& !self
.tombstones
.iter()
.any(|tombstone| tombstone.token == token)
}
pub fn fits(&self) -> Result<(), InviteError> {
if self.entries.len() > MAX_INVITE_ENTRIES {
return Err(InviteError::TooManyInvites(self.entries.len()));
}
let json = serde_json::to_string(self).map_err(json_error)?;
if json.len() > NIP44_MAX_PLAINTEXT {
return Err(InviteError::Oversize(json.len()));
}
Ok(())
}
}
pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList {
let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
for entry in held.entries.into_iter().chain(incoming.entries) {
match entries.entry(entry.token.clone()) {
Entry::Vacant(slot) => {
slot.insert(entry);
}
Entry::Occupied(mut slot) => {
let merged = merge_entry(slot.get(), &entry);
*slot.get_mut() = merged;
}
}
}
let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
match tombstones.entry(tombstone.token.clone()) {
Entry::Vacant(slot) => {
slot.insert(tombstone);
}
Entry::Occupied(mut slot) => {
if canonical(&tombstone) < canonical(slot.get()) {
*slot.get_mut() = tombstone;
}
}
}
}
let mut extra = held.extra;
union(&mut extra, incoming.extra);
InviteList {
entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(),
extra,
}
}
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
json.as_bytes(),
Version::V2,
)
.map_err(crypto_error)?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys)
.map_err(crypto_error)
}
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
if event.kind.as_u16() != KIND_INVITE_LIST {
return Err(InviteError::Kind(event.kind.as_u16()));
}
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
.map_err(crypto_error)?;
serde_json::from_str(&json).map_err(json_error)
}
/// An entry is immutable once minted, so two copies should agree.
fn merge_entry(held: &InviteEntry, incoming: &InviteEntry) -> InviteEntry {
let (winner, loser) = if canonical(incoming) < canonical(held) {
(incoming, held)
} else {
(held, incoming)
};
let mut merged = winner.clone();
union(&mut merged.extra, loser.extra.clone());
merged
}
fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> { fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
Ok(stream::seal_bytes( Ok(stream::seal_bytes(
&ConversationKey::new(*bundle_key), &ConversationKey::new(*bundle_key),
@@ -642,11 +803,6 @@ mod tests {
decode_fragment(&BASE64URL_NOPAD.encode(&unknown)).expect("decodes"); decode_fragment(&BASE64URL_NOPAD.encode(&unknown)).expect("decodes");
assert_eq!(decoded, token); assert_eq!(decoded, token);
assert!(relays.is_empty()); assert!(relays.is_empty());
let relays: Vec<String> = (0..4).map(|i| format!("wss://r{i}.example")).collect();
let (_, capped) =
decode_fragment(&encode_fragment(&token, &relays).expect("encodes")).expect("decodes");
assert_eq!(capped.len(), MAX_BOOTSTRAP_RELAYS);
} }
#[test] #[test]
@@ -678,10 +834,6 @@ mod tests {
parse_link("https://x/invite/#frag").is_err(), parse_link("https://x/invite/#frag").is_err(),
"the naddr is not optional" "the naddr is not optional"
); );
assert!(
parse_link("wss://relay.example.com").is_err(),
"nor the fragment"
);
} }
#[test] #[test]
+1
View File
@@ -5,6 +5,7 @@ pub mod edition;
pub mod guestbook; pub mod guestbook;
pub mod invite; pub mod invite;
pub mod list; pub mod list;
pub mod rekey;
pub mod roles; pub mod roles;
pub mod store; pub mod store;
pub mod stream; pub mod stream;
+2 -4
View File
@@ -43,7 +43,6 @@ impl fmt::Display for ListError {
impl std::error::Error for ListError {} impl std::error::Error for ListError {}
/// A membership's keys.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JoinMaterial { pub struct JoinMaterial {
pub community_id: CommunityId, pub community_id: CommunityId,
@@ -83,7 +82,6 @@ pub struct Tombstone {
pub extra: Extra, pub extra: Extra,
} }
/// A member's own memberships.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CommunityList { pub struct CommunityList {
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -242,7 +240,7 @@ fn pick<'a>(
held held
} }
fn union(into: &mut Extra, other: Extra) { pub(crate) fn union(into: &mut Extra, other: Extra) {
for (key, value) in other { for (key, value) in other {
let replace = match into.get(&key) { let replace = match into.get(&key) {
Some(existing) => canonical(&value) < canonical(existing), Some(existing) => canonical(&value) < canonical(existing),
@@ -255,7 +253,7 @@ fn union(into: &mut Extra, other: Extra) {
} }
} }
fn canonical<T: Serialize>(value: &T) -> String { pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default() serde_json::to_string(value).unwrap_or_default()
} }
File diff suppressed because it is too large Load Diff
+3
View File
@@ -120,6 +120,8 @@ pub struct CommunityState {
pub heads: Vec<EntityHead>, pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")] #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>, pub banned: BTreeSet<PublicKey>,
#[serde(default)]
pub dissolved: bool,
pub added_at_ms: u64, pub added_at_ms: u64,
} }
@@ -186,6 +188,7 @@ impl CommunityState {
relays, relays,
heads, heads,
banned: BTreeSet::new(), banned: BTreeSet::new(),
dissolved: false,
added_at_ms, added_at_ms,
}) })
} }
+7 -5
View File
@@ -259,21 +259,21 @@ pub fn wrap_seal_with(
pub fn rewrap_seal( pub fn rewrap_seal(
seal: &Event, seal: &Event,
new_group: &GroupKey, read: &GroupKey,
signer: &GroupKey,
at: Timestamp, at: Timestamp,
) -> Result<(Event, Keys), StreamError> { ) -> Result<(Event, Keys), StreamError> {
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT { if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
return Err(StreamError::NotRewrappable); return Err(StreamError::NotRewrappable);
} }
wrap_seal(seal, new_group, KIND_WRAP, at, &[])
wrap_seal_with(seal, read.conversation(), signer.keys(), KIND_WRAP, at, &[])
} }
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> { pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
open_wrap_at(wrap, &group.pk(), group.conversation(), false) open_wrap_at(wrap, &group.pk(), group.conversation(), false)
} }
/// Open and verify a wrap against a stream read view: the address to check and
/// the conversation key that opens the wraps, with no signing secret required.
pub fn open_wrap_at( pub fn open_wrap_at(
wrap: &Event, wrap: &Event,
address: &PublicKey, address: &PublicKey,
@@ -498,7 +498,8 @@ mod tests {
assert_eq!(opened.seal_form, SealForm::Plaintext); assert_eq!(opened.seal_form, SealForm::Plaintext);
let (rewrapped, _) = let (rewrapped, _) =
rewrap_seal(&opened.seal, &group(1), Timestamp::from_secs(2)).expect("rewraps"); rewrap_seal(&opened.seal, &group(1), &group(1), Timestamp::from_secs(2))
.expect("rewraps");
let reopened = open_wrap(&rewrapped, &group(1)).expect("opens"); let reopened = open_wrap(&rewrapped, &group(1)).expect("opens");
assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives"); assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives");
assert_eq!(reopened.author, author.public_key()); assert_eq!(reopened.author, author.public_key());
@@ -512,6 +513,7 @@ mod tests {
rewrap_seal( rewrap_seal(
&sealed(&edition, SealForm::Encrypted, &author), &sealed(&edition, SealForm::Encrypted, &author),
&group(1), &group(1),
&group(1),
Timestamp::from_secs(2) Timestamp::from_secs(2)
), ),
Err(StreamError::NotRewrappable) Err(StreamError::NotRewrappable)