mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 15:30:36 +00:00
docs: channel multi-owner plans: disclose forged-leave residual, define hold period and relay-reported re-send record
This commit is contained in:
@@ -65,13 +65,13 @@ Because local state thus stays "published + my in-progress changes" (an owner I
|
||||
|
||||
3. **True conflict.** When two owners changed one member without either having seen the other's change, it is a real conflict, not a dropped delta, and every owner resolves it the same way: a removal wins over a role change whatever their versions (the removed member's connections are already gone, and every device ignores a role change of a removed memberId, §6); otherwise the change made at the higher version wins, and at the same version the higher `authorMemberId`. The winner re-sends as above, so members — which apply equal-version events in arrival order — end on its value; the loser, applying the winner's change, surfaces "your change to X was overridden" instead of re-applying. Every role and removal event carries the key it replaced on its sender's device, so an owner whose own change is replaced surfaces it exactly when the replacing change was made without seeing it, and a later change made after seeing it surfaces nothing. No silent revert.
|
||||
|
||||
**The blob's author must stay verifiable.** Relays serve the stored blob as a message signed by its author (`serveRoster`, `Internal.hs:1295-1315`), which a joiner verifies only against an owner it holds with a key, and an existing member ignores a roster served under a departed author (`Subscriber.hs:4019-4020`). After an owner leaves or is removed a joiner has neither its key nor its owner role (its chain entry is dropped or listed in `revokedOwners`, §7). So when an owner's departure is applied, each remaining owner re-sends its current blob to relays at the version it holds, signed by itself, and a relay at an equal version prefers it to a blob whose author has departed (part 1). A relay that applies that departure only after the re-sends reached it — a removal it learns from its periodic link check, say — would have refused them; so a relay that applies the departure of its stored blob's author asks each connected current owner for its roster (`XGrpRosterRequest` with the relay's gate, proved as for a drop notice), which an owner answers by re-sending its blob to that relay at the higher of its own version and the relay's, raising its gate to it — an owner that never applied the departed owner's last change would otherwise be refused. It adds no version: members already hold every role, and a version without an event would read as a gap to every member (`Commands.hs:3015-3017`). Relays must forward roster events in the order they received them: a departing owner's earlier role events must not be overtaken by its departure, after which every device ignores them (`Subscriber.hs:4019-4020`), and no change may be overtaken by another owner's later one, which members would then drop below their gate with no gap to show it (`Subscriber.hs:3414-3425`). Today a relay batches the pending tasks of the oldest task's job scope (`Store/Delivery.hs:178-202`), and an event about a moderator or above gets a different group job scope from one about anyone else (`Delivery.hs:115-120`), so one half of a bulk change can be sent after a later event. Channel sends ignore that difference (`Subscriber.hs:4338-4340`), so in channels every group-scope event gets the one job scope; and the relay's batch packer, which today skips a task that does not fit and goes on accepting later, smaller ones (`Messages/Batch.hs:88-95`), stops at the first that does not fit. A relay then forwards them all in received order.
|
||||
**The blob's author must stay verifiable.** Relays serve the stored blob as a message signed by its author (`serveRoster`, `Internal.hs:1295-1315`), which a joiner verifies only against an owner it holds with a key, and an existing member ignores a roster served under a departed author (`Subscriber.hs:4019-4020`). After an owner leaves or is removed a joiner has neither its key nor its owner role (its chain entry is dropped or listed in `revokedOwners`, §7). So when an owner's departure is applied, each remaining owner re-sends its current blob to relays at the version it holds, signed by itself, and a relay at an equal version prefers it to a blob whose author has departed (part 1). A relay that applies that departure only after the re-sends reached it — a removal it learns from its periodic link check, say — would have refused them; so a relay that applies the departure of its stored blob's author asks each connected current owner for its roster (`XGrpRosterRequest` with the relay's gate, proved as for a drop notice), which an owner answers by re-sending its blob to that relay at the higher of its own version and the relay's, raising its gate to it — an owner that never applied the departed owner's last change would otherwise be refused. It adds no version: members already hold every role, and a version without an event would read as a gap to every member (`Commands.hs:3015-3017`). Relays must forward roster events in the order they received them: a departing owner's earlier role events must not be overtaken by its departure, after which every device ignores them (`Subscriber.hs:4019-4020`), and no change may be overtaken by another owner's later one, which members would then drop below their gate with no gap to show it (`Subscriber.hs:3414-3425`). Today a relay batches the pending tasks of the oldest task's job scope (`Store/Delivery.hs:178-202`), and an event about a moderator or above gets a different group job scope from one about anyone else (`Delivery.hs:115-120`), so one half of a bulk change can be sent after a later event. Channel sends ignore that difference (`Subscriber.hs:4338-4340`), so in channels every group-scope delivery job of an event gets the one job scope (the relay's own-removal job stays separate); and the relay's batch packer, which today skips a task that does not fit and goes on accepting later, smaller ones (`Messages/Batch.hs:88-95`), stops at the first that does not fit. A relay then forwards them all in received order.
|
||||
|
||||
**Catch-up from relays, not blobs.** Today an owner fetches the stored roster on any gap in the versions it has received — its own changes leave one, as they advance only its gate (`Subscriber.hs:3438-3446`, `Internal.hs:2644-2645`) — and a completed roster reverts every member absent from it (`Subscriber.hs:3610-3619`); an unkeyed blob would overwrite the owner's per-member keys, and a blob below the owner's gate, or its own blob served back, is never applied at all (`:3518`, `Store/Messages.hs:320-330`). So owners stop requesting and applying blobs, and catch up from signed events instead: each relay keeps, for each member, a copy of the latest owner's role or removal event it applied — signed body, signatures and author, so neither message cleanup nor member cleanup can take it away; an owner asks each relay at chat start (`XGrpRosterRequest`, carrying the highest version it has been served by that relay) and the relay answers with those events above it, which the owner applies by key like any other — bringing back an owner whose queued events expired while it was away (servers keep messages 21 days by default, `defMsgExpirationDays`). A relay drops everything for an owner whose queue on it is full until that owner's connection resumes, the answer to that request included, so on that resumption the relay sends a current owner its catch-up unasked, through the reactivated connection, and counts a version as served only once a send was actually handed to its agent. And when a blob that completes on a relay reverts a member whose latest event there is by another owner, the relay passes that event to every current owner: the blob's author, whose blob was made without it, applies it by key; and an owner that holds that very change — when its author has since departed, nobody else can — re-sends it under its own signature.
|
||||
|
||||
**A departing owner's changes outlive it.** Every device ignores content from an owner that has left or been removed (`Subscriber.hs:4019-4020`) — accepting it would let a removed owner, through one relay, keep changing roles — yet a departing owner's last changes are often still in flight: the creator changes a role and then leaves, and the new owner reconciles the link data, which shows the revocation the leave writes first (§7), before a relay has forwarded the change; a member does the same on channel open; an owner back after its queued events expired learns of the departure at chat start. Left alone, the change would end up on some members and relays only — when it was a demotion, relays and joiners would keep the member's old privileged role. Remaining owners carry a leaver's changes, in three steps; members and relays keep ignoring departed authors.
|
||||
|
||||
- **Hold, for a leave.** A relay forwards events in the order it received them (above), so a leaver's events reach every device, through each relay, before its `x.grp.leave`. An owner that learns of a leave from the link data first (a `revokedOwners` entry marked as a leave, §2) keeps applying the leaver's role changes, and removals of members, by key as before — whether they arrive forwarded, passed on, in a relay's departure report or as catch-up — until it processes the leaver's `x.grp.leave` (which it does for this even though it already holds the author as departed) or a removal of the leaver, or every relay that was current and connected when the hold started has reported applying the departure (below), or, for a relay that never reports, a fixed period has passed during which the device heard from the channel's relays; time spent offline, or with the relays down, never ends a hold. Only originals (not retries) are held, made at a version no higher than the one published with the leave — capped at the holder's own version plus the departed-owner margin (part 2), far above any real gap between owners, since the entry's writer asserts it — never naming an owner, and verified against the key kept with the removal record, as member cleanup may have deleted the leaver's row. A leaver colluding with a relay can thus, until the hold ends, make only a change it could have made before leaving; it cannot raise a gate or touch an owner. A removal starts no hold: the remover cannot vouch for the removed owner's last changes, which may be the very reason for the removal.
|
||||
- **Hold, for a leave.** A relay forwards events in the order it received them (above), so a leaver's events reach every device, through each relay, before its `x.grp.leave`. An owner that learns of a leave from the link data first (a `revokedOwners` entry marked as a leave, §2) keeps applying the leaver's role changes, and removals of members, by key as before — whether they arrive forwarded, passed on, in a relay's departure report or as catch-up — until it processes the leaver's `x.grp.leave` (which it does for this even though it already holds the author as departed) or a removal of the leaver, or every relay that was current and connected when the hold started has reported applying the departure (below), or, for a relay that never reports, a fixed period has passed during which the device heard from the channel's relays; time spent offline, or with the relays down, never ends a hold. Only originals (not retries) are held, made at a version no higher than the one published with the leave — capped at the holder's own version plus the departed-owner margin (part 2), far above any real gap between owners, since the entry's writer asserts it — never naming an owner, and verified against the key kept with the removal record, as member cleanup may have deleted the leaver's row. A leaver colluding with a relay can thus, until the hold ends, make only a change it could have made before leaving; it cannot raise a gate or touch an owner. A removal starts no hold: the remover cannot vouch for the removed owner's last changes, which may be the very reason for the removal. A leave entry cannot be told from one a removed owner forged with its own key before its key was dropped, so such an owner can, with a relay, have changes to non-owner members held by an owner that reads that entry first, and adopted when the hold ends — until its remover's announcement, or, if the remover never announces, until its own `x.grp.leave` or the hold period (§8).
|
||||
- **Adoption.** When an owner applies another owner's departure — the end of its hold, the departure event when it learned of the departure from it, or the announcement of its own removal of that owner — it re-sends, as retries, every member whose key the departed owner authored and that it has not already re-sent, with its blob (part 2's re-send; when none is left, the blob alone at the version it holds, which reads as no gap): the departed owner can no longer re-send its own changes, and devices that learned of the departure first dropped them. Retries carry the departed owner's key, so every owner compares them alike, and a member that already holds the value sees no change.
|
||||
- **Undo.** A role change the departed owner made that no remaining owner holds — one racing its removal, or one a device received after the departure event through a slower relay — may still have been applied by some members and relays. A relay that applies an owner's departure passes its kept copies of that owner's events to every current owner and then reports that it applied the departure; catch-up does the same for an owner that was away. An owner that has applied the departure and holds a different key for that member re-sends that member's current value — at most once for that member and its own current key, and at most once per relay per interval, so neither a replayed nor a fabricated copy can make it re-send more. If another remaining owner does hold that change, it rejects the undo as a losing different value and re-sends the change (part 2), and every owner adopts it by key. Either way every device ends with one role per member. A departed owner's removal that a remaining owner does not hold cannot be undone — a removal record is permanent (§6) — and is not adopted, since a removed owner and a relay could fake one: the owner is shown that the departed owner removed that member on some devices, and finishes the removal itself if it agrees.
|
||||
|
||||
@@ -130,6 +130,7 @@ Add the target's `memberId` to **`revokedOwners`** in the mutable link data (`LS
|
||||
- **Promotion is only as safe as the verified key.** An owner's copy of a subscriber's key is relay-asserted (from unsigned `XGrpMemNew`), so a relay that substituted it could otherwise be promoted. The defence is out-of-band verification, which is **already implemented** for channels (`verifyChannelMemberCode`, `Commands.hs:3826`, called at `:2074`, hashes both members' keys, sorted, so comparing detects substitution). It is **advisory**: the promoter is warned in the UI to verify before promoting (matching the existing channel model), not hard-blocked in the backend. Residual risk: an owner who ignores the warning and promotes an unverified member could sign a relay-substituted key into the chain, making that relay an owner. With one key per owner, the key that gains link-queue authority is exactly the member key the promoter was asked to verify; the backend also enforces the consistency check (§6) that the accepted key equals the one O1 already holds.
|
||||
- **Members take an owner's key only from a validated chain entry** — from the link data at join, or from `x.grp.owner.add` (§6) — never from a relay-asserted intro.
|
||||
- **Any owner can destroy the channel** (`Server.hs:1289-1290`: any recipient key authorises `DEL`/`LDEL`) and can RKEY the key set down to itself, evicting the others. Both accepted under any-owner-decides; recorded because they exceed any chat-level action.
|
||||
- **A removed owner can forge a leave while its key is live.** Between its remover's revocation and key drop — indefinitely if the remover stops there for good — it can rewrite its own `revokedOwners` entry as a leave, and with a colluding relay have changes to non-owner members (roles, and removals with their messages) held by an owner that reads that entry first and re-sent under that owner's signature when the hold ends (§4). A holder cannot tell this from a real leave, both being written with the leaver's key; the hold's version cap keeps it from raising any gate, its period bounds the window, and the remover's announcement ends it. Accepted as a residual of an interrupted removal.
|
||||
- **Link writes are attributable to owners, and a promoted owner's network address is visible to the link server.** Any link holder can tell which owner signed each version of the link data (§3). Recipient commands are not proxied — the proxy forwards only `SEND`, `SKEY`, `LKEY`, `LGET` and `RSLV` (`Server.hs:2205-2211`) — so a promoted owner's device connects directly to the link server the creator chose, and that server's operator, holding the public link, can tie the owner's network address to its channel identity. The invitee's confirmation says so (§10), and a commit makes M an owner on its own device only after M accepted (§6) — otherwise any owner could make a subscriber's device connect directly to the link server; Tor or SOCKS avoids the exposure. Foreign-link commands use a transport session of their own, one per link (the agent shares a session per user and server by default, `mkTSession`, `Agent/Client.hs:1442-1443`), so the link server cannot also tie them to the owner's other queues on that server.
|
||||
- **The creator is identifiable among owners**, contrary to the overview's claim that root-signing several owners conceals it (`channels-overview.md:159`): its chain entry is first, the link data it writes is signed with the root key (`Agent.hs:1076`), and every owner it promotes appears root-signed in a promotion it announces. Ordering the chain by memberId would not hide it while the creator root-signs the owners it adds (§6). Qualify the overview.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user