mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 12:53:18 +00:00
plan improvements
This commit is contained in:
1 parent
f5a06c0d42
commit
77b1a711c3
1 file changed
+15
-12
@@ -21,11 +21,11 @@ Link data and keys already *have* a sequencer — the server — so they need on
|
||||
|
||||
**What:** the mutable link blob — channel profile, relay list, owners chain, subscriber count. Read by every joiner; written by any owner. The server stores it encrypted and cannot read it.
|
||||
|
||||
**Sequencer — a version on the server, server-assigned.** Add `link_data_version` to the queue record. `LSET` carries `expectedVersion` = *the version the writer's blob is merged onto*; the server accepts iff `stored == expectedVersion`, increments, and **returns the new version in the success reply**. A mismatch **rejects and returns `(currentVersion, currentBlob)`**. The owner never picks or assumes a version: it takes the current one from events (below), sends it as `expectedVersion`, and on success echoes the server-confirmed new version into its broadcast. The rejection returns the full state because a rejected owner cannot know whether it missed an event or genuinely raced — it re-merges its one change onto the returned blob and retries.
|
||||
**Sequencer — a content hash for the server CAS, a version inside the data for event ordering.** These are two tokens serving two layers. The server's CAS token is the **hash of the stored encrypted user-data blob** — the only thing the server can key on, since it cannot read the content. `LSET` carries the expected hash; the server accepts iff `hash(stored) == expected`, stores the new blob, and a mismatch **rejects and returns the current blob**. The owner learns the hash without a round-trip: it hashes the bytes it uploaded on success, or the returned blob on a rejection — never assuming it. Separately, a **monotonic `version` lives inside the mutable data** (invisible to the server); it exists only so recipients can **reject stale events** — an event whose version is below what a recipient already holds is ignored. So: hash = server CAS token (server-visible, unordered); in-content version = client-side ordering (server-blind). A rejected owner re-merges its one change onto the returned blob and retries.
|
||||
|
||||
**The version travels in events, so writers rarely pre-read.** Carry `link_data_version` in the signed events that change link data (`XGrpInfo`, `XGrpRelayNew`, the owner add/remove/revoke events, the count publish), each carrying the version the *server confirmed* for that change. Owners hold current state + version from those events, so a write uses the version it holds and succeeds — the only rejections are genuinely simultaneous writes. **Order: write first (`LSET`-CAS), then broadcast the event with the server-confirmed version** — a rejected-then-retried write would otherwise announce the wrong version. **`LGET` is never needed to write** — a stale writer is simply rejected and gets the current state back.
|
||||
**Both tokens travel in events, so writers rarely pre-read.** The events that change link data (`XGrpInfo`, `XGrpRelayNew`, the owner add/remove/revoke events, the count publish) carry the new `(version, hash)`. Owners hold current state + version + hash from those events, so a write CASes on the hash it holds and succeeds — the only rejections are genuinely simultaneous writes. **Order: write first (`LSET`-CAS), then broadcast the event with the hash of what was written** — a rejected-then-retried write would otherwise announce the wrong hash. **`LGET` is never needed to write** — a stale writer is simply rejected and gets the current blob back.
|
||||
|
||||
**One reconcile routine keeps local state in sync; `groupLinkData` always builds from local.** "Reconcile" merges published link data into local state per field: sets (relays, owners, revoked-owners) merge and keep the owner's own pending intent; a whole-value field (profile/prefs) with a genuine concurrent same-field edit surfaces a conflict. Because local state thus stays "published + my pending intents", `groupLinkData` builds the write from local state — no separate delta step. Reconcile runs in three places, all the same code: **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — the owner-exclusion is removed — to adopt others' changes, connect to newly-added relays, and correct revocations), **on a write rejection** (adopt the returned blob, then rebuild), and as the missed-event backstop. The owners field appends my entry; the revoked-owners field appends (the remover writes it on removal, the leaver on leave, any owner corrects an inconsistency it sees on reconcile); the count comes from relays (§5).
|
||||
**One reconcile routine keeps local state in sync; `groupLinkData` always builds from local.** "Reconcile" merges published link data into local state per field: sets (relays, owners, revoked-owners) merge and keep the owner's own pending intent; a whole-value field (profile/prefs) with a genuine concurrent same-field edit surfaces a conflict. Because local state thus stays "published + my pending intents", `groupLinkData` builds the write from local state — no separate delta step. Reconcile runs in three places, all the same code: **on channel open** (owners run `APIGetUpdatedGroupLinkData` like subscribers — the owner-exclusion is removed — to adopt others' changes, connect to newly-added relays, and correct revocations), **on a write rejection** (adopt the returned blob, then rebuild), and as the missed-event backstop. The `owners` list is **sorted by `memberId`** (a canonical order giving a stable leader index and deterministic serialization); a write inserts my entry in place. This is clean when every entry is root-signed (§6 default, order-independent); a *delegated* entry must currently follow its signer (`validateLinkOwners` accepts "signed by root or an earlier entry"), so a memberId sort is only fully compatible if that validation is made order-independent (validate by anchoring each entry to root through the set) — a simplexmq change flagged in §11. `revokedOwners` is a list of `memberId` in the mutable data, append-only (the remover writes it on removal, the leaver on leave, any owner corrects an inconsistency it sees on reconcile). The count comes from relays (§5).
|
||||
|
||||
**One more existing defect:** relay activation must key off an *accepted* write, not the `LINK` echo (`Agent.hs:1813` returns the client's own sent data, so today an owner marks a relay active because it asked to — nothing reads back the truth).
|
||||
|
||||
@@ -33,11 +33,11 @@ Link data and keys already *have* a sequencer — the server — so they need on
|
||||
|
||||
**What:** the set of recipient auth public keys allowed to write the link queue = the owners' link-write keys. A promoted owner generates one (`linkRcvKey`) and it is added via `RKEY`.
|
||||
|
||||
**Sequencer — the server, version-CAS, same shape as link data.** Give the key set a `key_set_version`; `RKEY` carries the expected version and the full set; success returns the new version; a rejection returns the current set. Two concurrent additions: one wins, the other re-merges its key onto the returned set and retries, so both land. CAS is needed because `RKEY` *replaces* — without it, two concurrent adds each drop the other's key.
|
||||
**Sequencer — the server, hash-CAS.** The CAS token is the hash of the canonically-sorted key set. Unlike link data, the key set is *not* nonce-encrypted — the server holds the keys in the clear and any owner knows them (via the map below) — so **both sides compute the same hash from the content, and an owner rarely needs a rejection or an event to learn it**: it just hashes the set it believes is current. `RKEY` carries the expected hash and the full new set; a mismatch rejects and returns the current set. Two concurrent additions: one wins, the other re-merges its key onto the returned set and retries, so both land. CAS is needed because `RKEY` *replaces* — without it, two concurrent adds each drop the other's key.
|
||||
|
||||
**Correlation for removal — a signed map, cached on relays, never on the link server.** To remove owner X you must know *which* key is X's, but the server's set is deliberately **anonymous**: any public or link-server key→owner map lets a joining SMP operator (who reads the OwnerAuth chain and sees which key writes) tie an owner's link-queue activity to their identity. So the `memberId → linkRcvKey` map lives elsewhere: a new owner announces its key (own protocol event, delivered owner-scoped via `getGroupOwners`/a `DJSOwners` scope, carrying the `key_set_version` from its `RKEY` success), and — critically — **relays cache the signed map and serve it on request**. Each entry is owner-signed, so a relay cannot forge one; a relay sees the map but is not the link-queue operator, so it cannot correlate link-queue writes. Because relays are always-on, an owner can always obtain the map without waiting for a peer owner.
|
||||
**Correlation for removal — a signed map, cached on relays, never on the link server.** To remove owner X you must know *which* key is X's, but the server's set is deliberately **anonymous**: any public or link-server key→owner map lets a joining SMP operator (who reads the OwnerAuth chain and sees which key writes) tie an owner's link-queue activity to their identity. So the `memberId → linkRcvKey` map lives elsewhere: a new owner announces its key (own protocol event, delivered owner-scoped via `getGroupOwners`/a `DJSOwners` scope), and — critically — **relays cache the signed map and serve it on request**. Each entry is owner-signed, so a relay cannot forge one; a relay sees the map but is not the link-queue operator, so it cannot correlate link-queue writes. Because relays are always-on, an owner can always obtain the map without waiting for a peer owner. The map is also what lets an owner compute the key-set hash locally (above).
|
||||
|
||||
**So removal never blocks and self-heals:** it is an **async command** whose steps chain by continuation (fetch the map from a relay if the local copy lacks the target → `RKEY` out the key → `LSET` the revocation), CAS rejections retrying inline — no new durable-worker type. A crash mid-removal is corrected by reconcile-on-open (§2): any owner that sees an `OwnerAuth` whose key is no longer in the set treats it as revoked and completes the marker. A missing *addition* self-heals via CAS (stale `key_set_version` → rejection → returned set repairs it). A relay withholding the map is handled by asking another relay.
|
||||
**So removal never blocks and self-heals:** it is an **async command** whose steps chain by continuation (fetch the map from a relay if the local copy lacks the target → `RKEY` out the key → `LSET` the revocation), CAS rejections retrying inline — no new durable-worker type. A crash mid-removal is corrected by reconcile-on-open (§2): any owner that sees an `OwnerAuth` whose key is no longer in the set treats it as revoked and completes the marker. A missing *addition* self-heals via CAS (a stale set hashes wrong → rejection → returned set repairs it). A relay withholding the map is handled by asking another relay.
|
||||
|
||||
## 4. Roster
|
||||
|
||||
@@ -59,7 +59,7 @@ Link data and keys already *have* a sequencer — the server — so they need on
|
||||
|
||||
**Source — absolute per-relay counts, take the max.** Each relay states its own *current absolute* subscriber count as a distinct per-relay event **batched with the join/leave/removal event it already sends to owners** — so it costs no extra SMP block, and it need only be restated when the count changes, which is exactly on a join/leave/removal (no periodic reporting). The owner stores the latest **per relay** (a field on the relay's member row); the published count is the **max** across relays. Absolute-plus-max makes the hard case correct: a newly added relay that subscribers are still slowly connecting to reports a low number that stays below the max and is ignored until it catches up; a subscriber leaving that relay drops only its entry, not the max; a real departure (from all relays) lowers every entry and so the max. **Deltas fail here** — a leave is forwarded by several relays and *deduplicated*, so an owner cannot attribute a decrease to a relay, and a low new relay's decrease would wrongly crash the total. The count event, being a separate per-relay event, is not deduplicated even though the join/leave it batches with is. (Relays need not talk to each other; the max of independent reports suffices.)
|
||||
|
||||
**Sequencer — a leading owner** (the lowest `owner_auth_index` among current owners) publishes the count to link data. Not for correctness (relay-sourcing already fixed the promoted-owner-undercount) but for **efficiency**: the count shares `link_data_version`, so N owners publishing per join would bump it constantly and starve human writes; one publisher is one stream. **Handover follows chain order on a membership change** (tentative): when the leader is removed or leaves, the next owner in `owner_auth_index` order automatically becomes the publisher — no liveness detection. A leader that is merely *offline* is not handed over; the count just goes stale until it returns, which is fine because the count is cosmetic. A new owner seeds from the published value and does not publish until it has its own relay reports.
|
||||
**Sequencer — a leading owner** (the lowest `memberId` among current owners — a stable order every device computes the same way) publishes the count to link data. Not for correctness (relay-sourcing already fixed the promoted-owner-undercount) but for **efficiency**: the count writes the shared link blob, so N owners publishing per join would each rewrite it and starve human writes; one publisher is one stream. **Handover follows that order on a membership change** (tentative — see §11): when the leader is removed or leaves, the next-lowest `memberId` owner becomes the publisher, no liveness detection. A leader that is merely *offline* is not handed over; the count just goes stale until it returns, which is fine because the count is cosmetic. A new owner seeds from the published value and does not publish until it has its own relay reports.
|
||||
|
||||
## 6. Owner promotion (adding an owner)
|
||||
|
||||
@@ -74,13 +74,15 @@ x.grp.promote.cancel { invitationId } -- O1 -> M
|
||||
```
|
||||
`memberRole` is the offered role; `roleData` is an optional role-scoped block (owner: `linkRcvId` in inv, fresh `linkRcvKey` in acpt); `memberKey` is M's existing key, which O1 must check **equals the key it already holds for M** — a consistency check so the acceptance cannot introduce a new key (distinct from the OOB verification in §8, which is advisory). A future role adds a `roleData` variant; the parser must decode an unknown tag to an opaque/ignored value (not error), since `omittedField` only covers an *absent* field.
|
||||
|
||||
**Consent:** acceptance is not automatic — M confirms in the UI. A pending record on each side; reject or cancel clears it. On acceptance O1 chains, as an async command resumable from the pending record (no new worker type — the record is the durable state, re-driven on receipt and on startup): `RKEY` (add M's key, §3) → `LSET` (append M's `OwnerAuth`, §2) → `x.grp.mem.role … GROwner` (the **commit point** — M treats itself as owner only on receiving this). O1 also announces M's key to the other owners and sends M the current key-map (§3).
|
||||
**Consent:** acceptance is not automatic — M confirms in the UI. A pending record on each side; reject or cancel clears it. On acceptance O1 chains, as an async command resumable from the pending record (no new worker type — the record is the durable state, re-driven on receipt and on startup): `RKEY` (add M's key, §3) → `LSET` (insert M's `OwnerAuth` in the sorted `owners`, §2) → `x.grp.mem.role … GROwner` (the **commit point** — M treats itself as owner only on receiving this). O1 also announces M's key to the other owners and sends M the current key-map (§3).
|
||||
|
||||
**Sign new owners with the root key when the adder holds it** (i.e. the creator, who keeps the root private key). Root-signed entries are each independently valid — `validateLinkOwners` accepts any entry signed by root — so all owners sit at one level and the list has no delegation dependencies. A non-creator owner has no root key, so it must sign with its member key (delegation), and *those* entries depend on their signer; the creator being the usual adder keeps the list flat, which is what makes removal (§7) simple.
|
||||
|
||||
**Transport is not confidential, and that is fine.** Support scope reaches M plus all moderators, so a moderator sees the invitation — but nothing secret is in it (`linkRcvId`/`linkRcvKey` are inert public data), and a moderator that *injects* a fake `x.grp.promote.inv` achieves nothing: completion needs an owner holding a matching pending record, which no one does, so no key or link write ever happens. M additionally verifies the `.inv` is owner-signed and ignores others.
|
||||
|
||||
## 7. Owner removal
|
||||
|
||||
Drop the leaver's key from `recipientKeys` (`RKEY`, targeting it via the owner-held map §3) and mark the entry **revoked** in link data. **Keep the `OwnerAuth` entry** — the chain is append-only, so removing an entry would break every owner it transitively signed; a revoked entry stays valid as a past signer but is not a current owner (a revoked-id list in the link user-data) and its key is gone, so it can no longer write anything. Enforcement is therefore at the SMP layer (no key = no authority), not in the chain. **Leave** is self-sequenced: an owner removing itself writes the revoked-marker first and drops its own key last; if it crashes, a remaining owner finishes. Removal is not a defence against a malicious owner (any owner can already destroy the channel) — it is administrative hand-off.
|
||||
Drop the leaver's key from `recipientKeys` (`RKEY`, targeting it via the owner-held map §3) and add its `memberId` to **`revokedOwners`** in the mutable link data. **Keep the `OwnerAuth` entry** rather than deleting it — a revoked entry stays valid for verifying the owner's *past* signed messages, `revokedOwners` marks it as no longer current (so a new joiner does not treat it as an owner), and its key is gone so it can no longer write. This is uniform whether the entry is root-signed or delegated; for a delegated entry, keeping it also avoids breaking any owner it signed (root-signed entries — the common case, §6 — have no such dependency). Enforcement is at the SMP layer (no key = no authority), not in the chain. **Leave** is self-sequenced: an owner removing itself writes the revocation first and drops its own key last; if it crashes, a remaining owner finishes. Removal is not a defence against a malicious owner (any owner can already destroy the channel) — it is administrative hand-off.
|
||||
|
||||
## 8. Security
|
||||
|
||||
@@ -91,8 +93,8 @@ Drop the leaver's key from `recipientKeys` (`RKEY`, targeting it via the owner-h
|
||||
|
||||
## 9. Data model
|
||||
|
||||
- **SMP link queue (simplexmq server):** `link_data_version`, `key_set_version`. These wire changes gate on a new SMP **relay** version (`currentServerSMPRelayVersion`, `VersionSMP` 18 → 19 — *not* `currentSMPClientVersion`, which is the client↔client envelope and gates nothing server-facing). **Multi-owner requires the link queue's server ≥ v19**: below it, blind `LSET` is merely lossy, but blind `RKEY` *evicts* other owners (it replaces the set with no CAS), so promotion must **fail closed** on an old link server, not degrade.
|
||||
- **Agent store (owner device, both SQLite and Postgres trees):** the CAS version cached next to the link credentials; `linkRootSigKey` persisted (`AgentStore.hs:2514`).
|
||||
- **SMP link queue (simplexmq server):** the CAS token per mutable object — the **hash of the stored user-data blob** for link data, and the hash of the sorted key set for `recipientKeys` (the server computes each on write; no version counter). These wire changes gate on a new SMP **relay** version (`currentServerSMPRelayVersion`, `VersionSMP` 18 → 19 — *not* `currentSMPClientVersion`, which is the client↔client envelope and gates nothing server-facing). **Multi-owner requires the link queue's server ≥ v19**: below it, blind `LSET` is merely lossy, but blind `RKEY` *evicts* other owners (replace with no CAS), so promotion must **fail closed** on an old link server, not degrade.
|
||||
- **Agent store (owner device, both SQLite and Postgres trees):** the last-known link-data hash + in-content version cached next to the link credentials; `linkRootSigKey` persisted (`AgentStore.hs:2514`).
|
||||
- **Chat DB (owner device):** owners chain (`owner_auth_sig`/`owner_auth_index` on `group_members`, threaded through `createLinkOwnerMember`/`updateRelayGroupKeys`); pending-promotion record (member-row columns); pending role-deltas; `roster_version_owner_id`; per-relay absolute subscriber count (a field on each relay's member row); the `memberId → linkRcvKey` map. A `DJSOwners` owner-only delivery scope for the key announcement.
|
||||
- **Relay:** a cached signed `memberId → linkRcvKey` map, served on request (for owner removal).
|
||||
- **Apps (iOS + Kotlin):** `canManageLink` on `GroupInfo`; `MemberRoleProposal` (`MRProposed`/`MRRejected`) + `promotionPending` on `GroupMember`; a new `RcvGroupEvent` case for the promotion service item — all optional/forward-compatible so remote-desktop parsing across versions holds.
|
||||
@@ -106,7 +108,8 @@ Drop the leaver's key from `recipientKeys` (`RKEY`, targeting it via the owner-h
|
||||
|
||||
## 11. Open decisions
|
||||
|
||||
- **Count-leader handover** is tentative: chain-order takeover on a membership change (§5). The alternative — staleness-triggered takeover so a merely-offline leader is also replaced — is more available but needs liveness inference; deferred unless cosmetic staleness during a leader's absence proves unacceptable.
|
||||
- **Count-leader handover** is tentative: chain-order takeover on a membership change (§5). Since owners are often-offline phones, the lowest-memberId leader may be absent much of the time, leaving the count stale — so a staleness-triggered takeover (an online owner publishes when the count lags its own relay-derived value, staggered by memberId order so they don't all write at once, CAS resolving any overlap) is likely needed for the count to actually track. To brainstorm.
|
||||
- **`validateLinkOwners` order** (simplexmq): a memberId sort of `owners` (§2) coexists with delegated (non-root-signed) entries only if validation anchors each entry to root through the set rather than requiring "signed by an earlier entry". Decide whether to make it order-independent, or to require all owners root-signed (creator-only adds).
|
||||
|
||||
Settled this round: multi-owner hard-blocks on a pre-v19 link server (§9, no degraded mode); the roster approach is tie-break + auto-retry with no leader (§4); a same-member conflict is surfaced and re-applied through normal UI (§10).
|
||||
|
||||
|
||||
Reference in new issue
Block a user