From 0e09b38ea6834ae31a0e6af5700a9c869c5bb5d8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:15:41 +0000 Subject: [PATCH] core: public groups - roster of privileged members (#7017) --- apps/ios/SimpleXChat/ChatTypes.swift | 2 + .../chat/simplex/common/model/ChatModel.kt | 2 + .../commonMain/resources/MR/base/strings.xml | 1 + bots/api/TYPES.md | 13 + bots/src/API/Docs/Types.hs | 2 + bots/src/API/TypeInfo.hs | 1 + docs/protocol/channels-overview.md | 3 +- .../types/typescript/src/types.ts | 8 + .../src/simplex_chat/types/_types.py | 6 +- ...-05-26-public-groups-via-relays-unified.md | 227 +++++ plans/2026-06-01-roster-members-multipart.md | 220 +++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Library/Commands.hs | 134 +-- src/Simplex/Chat/Library/Internal.hs | 254 +++++- src/Simplex/Chat/Library/Subscriber.hs | 583 ++++++++++--- src/Simplex/Chat/Protocol.hs | 106 ++- src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Files.hs | 114 ++- src/Simplex/Chat/Store/Groups.hs | 288 ++++++- src/Simplex/Chat/Store/Messages.hs | 11 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260602_group_roster.hs | 64 ++ .../Store/Postgres/Migrations/chat_schema.sql | 75 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260602_group_roster.hs | 63 ++ .../SQLite/Migrations/chat_query_plans.txt | 238 +++++- .../Store/SQLite/Migrations/chat_schema.sql | 40 +- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/Types.hs | 39 + src/Simplex/Chat/Types/Shared.hs | 11 + tests/ChatClient.hs | 2 +- tests/ChatTests/Groups.hs | 776 ++++++++++++++++-- tests/ProtocolTests.hs | 10 +- 33 files changed, 2902 insertions(+), 411 deletions(-) create mode 100644 plans/2026-05-26-public-groups-via-relays-unified.md create mode 100644 plans/2026-06-01-roster-members-multipart.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 56f92cfc0b..d7478e5c3b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2741,6 +2741,7 @@ public enum RelayStatus: String, Decodable, Equatable, Hashable { case new case invited case accepted + case acknowledgedRoster case active case inactive case rejected @@ -2816,6 +2817,7 @@ extension RelayStatus { case .new: "new" case .invited: "invited" case .accepted: "accepted" + case .acknowledgedRoster: "acknowledged roster" case .active: "active" case .inactive: "inactive" case .rejected: "rejected" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index fa2051a761..ecfb58fdb0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2415,6 +2415,7 @@ enum class RelayStatus { @SerialName("new") New, @SerialName("invited") Invited, @SerialName("accepted") Accepted, + @SerialName("acknowledgedRoster") AcknowledgedRoster, @SerialName("active") Active, @SerialName("inactive") Inactive, @SerialName("rejected") Rejected; @@ -2423,6 +2424,7 @@ enum class RelayStatus { New -> generalGetString(MR.strings.relay_status_new) Invited -> generalGetString(MR.strings.relay_status_invited) Accepted -> generalGetString(MR.strings.relay_status_accepted) + AcknowledgedRoster -> generalGetString(MR.strings.relay_status_acknowledged_roster) Active -> generalGetString(MR.strings.relay_status_active) Inactive -> generalGetString(MR.strings.relay_status_inactive) Rejected -> generalGetString(MR.strings.relay_status_rejected) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 9630c61004..4ea07d4608 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3022,6 +3022,7 @@ new invited accepted + acknowledged roster active inactive rejected diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 4f3df9d365..b38e3f6c6e 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -87,6 +87,7 @@ This file is generated automatically. - [FileProtocol](#fileprotocol) - [FileStatus](#filestatus) - [FileTransferMeta](#filetransfermeta) +- [FileType](#filetype) - [Format](#format) - [FormattedText](#formattedtext) - [FullGroupPreferences](#fullgrouppreferences) @@ -2100,6 +2101,15 @@ NO_FILE: - cancelled: bool +--- + +## FileType + +**Enum type**: +- "normal" +- "roster" + + --- ## Format @@ -2316,6 +2326,7 @@ MemberSupport: - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? - customData: JSONObject? - groupSummary: [GroupSummary](#groupsummary) +- rosterVersion: int64? - membersRequireAttention: int - viaGroupLinkUri: string? - groupKeys: [GroupKeys](#groupkeys)? @@ -3317,6 +3328,7 @@ Cancelled: - xftpRcvFile: [XFTPRcvFile](#xftprcvfile)? - fileInvitation: [FileInvitation](#fileinvitation) - fileStatus: [RcvFileStatus](#rcvfilestatus) +- fileType: [FileType](#filetype) - rcvFileInline: [InlineFileMode](#inlinefilemode)? - senderDisplayName: string - chunkSize: int64 @@ -3441,6 +3453,7 @@ ParseError: - "new" - "invited" - "accepted" +- "acknowledgedRoster" - "active" - "inactive" - "rejected" diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 7b268f4ec5..6313c68838 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -270,6 +270,7 @@ chatTypesDocsData = (sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""), (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), + (sti @FileType, STEnum' (consLower "FT"), "", [], "", ""), (sti @Format, STUnion, "", ["Unknown"], "", ""), (sti @FormattedText, STRecord, "", [], "", ""), (sti @FullGroupPreferences, STRecord, "", [], "", ""), @@ -489,6 +490,7 @@ deriving instance Generic FileInvitation deriving instance Generic FileProtocol deriving instance Generic FileStatus deriving instance Generic FileTransferMeta +deriving instance Generic FileType deriving instance Generic Format deriving instance Generic FormattedText deriving instance Generic FullGroupPreferences diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 8dfba2bbb0..c5a3b11953 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -170,6 +170,7 @@ toTypeInfo tr = "DBEntityId'" -> ST TInt64 [] "Integer" -> ST TInt64 [] "Version" -> ST TInt [] + "VersionRoster" -> ST TInt64 [] "BoolDef" -> ST TBool [] "PQEncryption" -> ST TBool [] "PQSupport" -> ST TBool [] diff --git a/docs/protocol/channels-overview.md b/docs/protocol/channels-overview.md index d4cd2d2965..7a55f8b6e5 100644 --- a/docs/protocol/channels-overview.md +++ b/docs/protocol/channels-overview.md @@ -182,7 +182,7 @@ The low-level protocol supports multiple owners from the initial release. The ap - **Subscribers** connect to relays and receive content. They cannot send messages by default, but can be given posting rights. -Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol. +Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol. The owner-signed roster tracks the promoted set - members, moderators, and admins; subscribers are observers until an owner promotes them. For protocol-level detail - wire formats, message types, signing and verification mechanics, delivery pipeline - see [SimpleX Channels Protocol](./channels-protocol.md). @@ -242,6 +242,7 @@ This threat model assumes the [SimpleX network threat model](https://github.com/ - Undetectably substitute content - subscribers on honest relays receive the original. - Alter the channel's authoritative state on the owner's device. - Substitute the channel profile or impersonate an owner - these require valid signatures. +- Replay an old roster or role change to re-elevate a removed or demoted member for existing subscribers - they reject anything older than the roster version they applied (a new joiner with no prior roster can still be served an old one, until it syncs from another relay). - Redirect subscribers to a different channel - the entity ID is validated across link and profile. - Determine subscriber identity or network address - inherited from SMP transport. - Correlate subscriber participation across channels - each connection uses independent SMP queues. The subscriber chooses their SMP router independently, so collusion between a relay and the relay's SMP router does not compromise connections through a different router. diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 597f2f5503..323f174f91 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2369,6 +2369,11 @@ export interface FileTransferMeta { cancelled: boolean } +export enum FileType { + Normal = "normal", + Roster = "roster", +} + export type Format = | Format.Bold | Format.Italic @@ -2599,6 +2604,7 @@ export interface GroupInfo { uiThemes?: UIThemeEntityOverrides customData?: object groupSummary: GroupSummary + rosterVersion?: number // int64 membersRequireAttention: number // int viaGroupLinkUri?: string groupKeys?: GroupKeys @@ -3635,6 +3641,7 @@ export interface RcvFileTransfer { xftpRcvFile?: XFTPRcvFile fileInvitation: FileInvitation fileStatus: RcvFileStatus + fileType: FileType rcvFileInline?: InlineFileMode senderDisplayName: string chunkSize: number // int64 @@ -3806,6 +3813,7 @@ export enum RelayStatus { New = "new", Invited = "invited", Accepted = "accepted", + AcknowledgedRoster = "acknowledgedRoster", Active = "active", Inactive = "inactive", Rejected = "rejected", diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 9546bfe5a8..25825d8f98 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1662,6 +1662,8 @@ class FileTransferMeta(TypedDict): chunkSize: int # int64 cancelled: bool +FileType = Literal["normal", "roster"] + class Format_bold(TypedDict): type: Literal["bold"] @@ -1822,6 +1824,7 @@ class GroupInfo(TypedDict): uiThemes: NotRequired["UIThemeEntityOverrides"] customData: NotRequired[dict[str, object]] groupSummary: "GroupSummary" + rosterVersion: NotRequired[int] # int64 membersRequireAttention: int # int viaGroupLinkUri: NotRequired[str] groupKeys: NotRequired["GroupKeys"] @@ -2550,6 +2553,7 @@ class RcvFileTransfer(TypedDict): xftpRcvFile: NotRequired["XFTPRcvFile"] fileInvitation: "FileInvitation" fileStatus: "RcvFileStatus" + fileType: "FileType" rcvFileInline: NotRequired["InlineFileMode"] senderDisplayName: str chunkSize: int # int64 @@ -2667,7 +2671,7 @@ class RelayProfile(TypedDict): shortDescr: NotRequired[str] image: NotRequired[str] -RelayStatus = Literal["new", "invited", "accepted", "active", "inactive", "rejected"] +RelayStatus = Literal["new", "invited", "accepted", "acknowledgedRoster", "active", "inactive", "rejected"] ReportReason = Literal["spam", "content", "community", "profile", "other"] diff --git a/plans/2026-05-26-public-groups-via-relays-unified.md b/plans/2026-05-26-public-groups-via-relays-unified.md new file mode 100644 index 0000000000..91f7c3a6ce --- /dev/null +++ b/plans/2026-05-26-public-groups-via-relays-unified.md @@ -0,0 +1,227 @@ +# Plan: Public groups via relays (unified) + +Date: 2026-05-26 + +This plan is self-contained. It supersedes `2026-05-08-public-groups-via-relays.md` and folds in the privileged-roster mechanism understood since. Implementers should work from this document alone. File:line anchors are current as of this date — confirm before editing. + +## Overview + +Channels (shipped) are relay-mediated groups where the relay forwards content from owners only; subscribers are pinned to `GRObserver` and cannot post. Public groups are the second value of the same two-axis design: same wire, same transport, but every member can post, and there are moderators/admins who can act. + +| `useRelays` | `groupType` | Name | Posting | Notes | +|---|---|---|---|---| +| `false` | (none) | Secret group | all members | today's P2P full-mesh group | +| `true` | `GTChannel` | Channel | owners only | shipped; subscribers anonymous to each other | +| `true` | `GTGroup` | **Public group** | every member | **new**; member-to-member DMs deferred | +| `true` | `GTUnknown _` | (refuse) | — | newer-client link seen by older client → refuse to join | + +Three concepts, kept distinct: **transport** = `useRelays` (topology, batch, signatures, delivery); **governance** = `groupType` (who may post, member affordances); **joiner role** = the default role new joiners get, set by the owner on the signed profile. + +Two things make public groups work and neither exists today: + +1. **The joiner role must come from the owner-signed profile**, not a relay-side global config — otherwise relays disagree on the default role. (Section 2.) +2. **Members must learn who the moderators/admins are — their identity, signing key, and role — in a way a relay cannot forge.** Today a relay can fabricate a moderator (Section 1, Problem). This is the load-bearing piece and ships first. + +`GTGroup`, `PublicGroupProfile`, `useRelays'`, and the relay/signing/forwarding machinery already exist (anchors below). The work is additive. + +--- + +# Section 1 — Privileged roster (`XGrpRoster`) + +This is a **general relay-group mechanism**: public groups use it now; channels inherit it for their multi-owner/moderator future. It is the first, self-contained task. + +## 1.1 Problem and trust model + +Owners are trusted because their keys come from the **link**, never the relay: on join, `createLinkOwnerMember` (`Store/Groups.hs:3072`) writes each owner's `member_pub_key` from the link's `OwnerAuth` chain, validated against `publicGroupId == sha256(rootKey)`. `xGrpMemIntro` even nulls the key when `mRole == GROwner` (`Subscriber.hs:3029`): *"owner key must only come from link data, not from relay intro."* + +Non-owner privileged members have no such anchor. Today `xGrpMemIntro` **keeps** the relay-asserted key for `GRModerator`/`GRAdmin`, and `introduceInChannel` (`Internal.hs:1165`) introduces all of `getGroupModerators` (which returns mod+admin+owner, `Store/Groups.hs:1190`). So a malicious relay can assert "X is a moderator, here is X's key," and the subscriber will then trust the relay-chosen key to verify X's signed administrative actions (`XGrpMemDel`, `XGrpMemRestrict`, `XGrpMemRole`). The `when (memRole > GRMember)` gate in `xGrpMemNew` (`Subscriber.hs:2957`) blocks the *dissemination* path but not the *join-time intro* path — the protection is half-applied. Dormant for channels (single-owner broadcast), activated by public groups. + +**Conclusion:** a non-owner privileged member's `(memberId, name, key, role)` must be **owner-signed**, exactly like owners are link-signed. That is the roster. + +## 1.2 Wire event and signing + +New event `XGrpRoster` (add to `ChatMsgEvent`, `Protocol.hs:422`), JSON-encoded, carrying: + +- `version :: Word32` — monotonic, from 0. +- `roster :: [{ memberId, name, key, role }]` — the complete current privileged set, `role ∈ {GRModerator, GRAdmin}`. `name` is a display name only (to avoid ugly "unknown member" records, as `XGrpMsgForward` already carries one). Owners are **not** in the roster. + +Add `XGrpRoster_` to `requiresSignature` (`Protocol.hs:1231`) ⇒ `True`. This makes the owner sign it via the existing `groupMsgSigning` (`Internal.hs:1962`, binding `CBGroup <> (publicGroupId, ownerMemberId)`, key `groups.member_priv_key`) and makes recipients require a valid owner signature via `withVerifiedMsg` (`Subscriber.hs:3461`). No new crypto. + +**The handler MUST assert the resolved author is an owner** (`memberRole' author == GROwner`). `withVerifiedMsg` verifies the signature against the *author's* key, and the relay chooses `fwdSender` — so without this assertion a relay could route a roster as a member whose key it controls and the signature would verify. Owners exist on recipients only via the link `OwnerAuth` chain, so a relay can neither fabricate an owner nor sign as one. This assertion is the crux of the roster's integrity. + +## 1.3 Authoritative model — versioned snapshot, latest-wins, TOFU keys + +Each `XGrpRoster` is the complete current privileged set. Recipients treat the highest-version valid roster as authoritative for *who is privileged and their keys*; absence from the newest accepted roster means *not privileged* (reverts to the joiner default unless an accompanying `XGrpMemRole` sets a specific role — see 1.6). This is self-healing: a member who missed one change gets the full current state on the next roster. + +**Key handling is trust-on-first-use, pinned per `memberId`** (per entry): + +- `memberId` unknown, or known without a key → store the key (first sight, from the owner). Set name/role. +- `memberId` already has a key: + - same key → fine; update name/role. + - **different key → error.** Never overwrite; keep the old key; surface a suspicious-roster event. + +There is **no in-place key rotation**: a genuine re-key is modeled as a *new member* — the owner removes the old `memberId` (`XGrpMemDel` + roster drop) and adds a new `memberId` with the new key. Consistent with SimpleX's no-mutable-identity stance, and with the `xGrpMemNew` rule in 1.5. + +**Anti-replay / rollback.** The relay cannot forge a signed roster but can replay an older one. + +- The current roster `version` is anchored in the owner-controlled link mutable data (which already holds `OwnerAuth`, profile, subscriber count). The relay cannot forge it. A roster change that bumps the version also updates link data. **Status:** the write side is implemented; the join-time **read/detect** is deferred — comparing the anchor against the relay-served roster at join is racy (the forwarded roster may not have arrived yet → false positives), so correct staleness detection must be triggered by roster arrival, not at join. The residual new-joiner rollback gap below stands; the hard anti-replay (member + relay) is in place. +- **Existing members** reject any roster with `version` below the highest already accepted — full anti-replay for them. +- **New joiners** process the latest version the relay actually serves, even if it lags the link anchor, so honest relay propagation lag never blocks a join. The anchor is used for staleness *detection*, not a hard gate, in v1. +- **Documented residual gap:** a stale/malicious relay can serve an old-but-valid roster to a brand-new joiner. Documented in `channels-overview.md` with future mitigations: escalate verification to an owner, or have the client compare the relay's version to the link anchor and refuse/retry above a staleness threshold. + +## 1.4 Cap on the privileged set + +Bound the privileged set so the signed roster always fits one message — never paginate. A hard **cap on moderators + admins** (owners are on the link, not counted), enforced **at promotion time** on the owner: refuse to elevate beyond the cap with a clear error, so the roster is always constructible as one signed message. + +Derive the number from the single encoded-message budget (verify the exact constant — the encoded-message-length limit minus signature + JSON overhead) divided by worst-case entry size. With `{memberId, name, key, role}` entries this is comfortably in the tens-to-~100 range; pick the final value from the measured worst-case entry. + +## 1.5 Remove the dissemination gates; gate on the roster instead + +Because the relay forwards the roster on join **before anything else**, a privileged member's key/role is owner-established before any relay-asserted introduction arrives. So the relay may now disseminate privileged members' full profiles like any other member, and the gates come out: + +- **Remove** the `when (memRole > GRMember)` throw in `xGrpMemNew` (`Subscriber.hs:2957`). +- **Remove** the forward-side `memberRole' s <= GRMember` filter in `sendBodyToMembers` (confirm exact site in `Subscriber.hs`). +- **Replace** with a roster check in `xGrpMemNew`: for an announcement of a privileged role, require that a member record with that `memberId` already exists **with that privileged role** (roster-established). If found → accept the **profile** update only; **never overwrite `key`, `memberId`, or `role`** (roster-authoritative). If not found → reject (a relay conjuring a privileged member not in the roster). + +`introduceInChannel` forwards the cached roster to the new member first, then proceeds; it may still announce the newcomer to moderators and maintain the relations vector. The per-mod `XGrpMemIntro` carrying keys is no longer the trust path for privileged members. + +## 1.6 Delivery — what is sent, when, to whom + +Two orthogonal axes, and the roster owns only one: + +- **Axis A — privileged set + keys** (who is mod/admin, their key/name/role): owned by the roster. +- **Axis B — group-membership lifecycle** (removed / restricted / left): owned by `XGrpMemDel` / `XGrpMemRestrict` / `XGrpLeave`, unchanged, applies to everyone. + +Dispatch by whether an operation touches the {moderator, admin} set (the *roster roles*). Owner is not a roster role — promotion to/from owner uses `XGrpMemRole` (+ link `OwnerAuth`), never the roster. + +**`APIMembersRole` (role change, possibly batched):** + +- Emit `XGrpMemRole(M, target)` for each affected member exactly as today — this conveys the exact target role for any role, including owner and specific ≤member roles. +- **Additionally** build and **broadcast** the full signed roster (version++) **iff the {mod, admin} set changed** (any member entered, left, or moved within mod/admin). +- A mixed batch fires both. Example: target=member over `[moderator M, observer O]` → `XGrpMemRole` for both (exact roles) **and** a roster (M left the set). The promotion case `XGrpMemRole(M, mod)` + roster is mildly redundant on the role field and harmless; the key comes only from the roster. + +The broadcast reuses the existing owner-admin-event forwarding (`shouldForward = isUserGrpFwdRelay gInfo && not forwarded`, `Subscriber.hs:3191`). Privileged-set changes are rare administrative events, so this is on the order of an `XGrpInfo`/`XGrpPrefs` broadcast — not per-message. The broadcast roster is the **self-healing** mechanism: a member who missed a prior `XGrpMemRole` is corrected by the snapshot. + +**`XGrpMemDel` (removal):** broadcast `XGrpMemDel` as today (it neutralizes the member for existing members). If the removed member was privileged, the owner sends a refreshed roster (version++), which the relay broadcasts like any other version bump (see below). + +**Relay broadcast rule — always broadcast on a strict version bump.** A newer-version roster is applied, cached, and broadcast to current members, uniformly — promotion, key/role change, demotion, or privileged removal. We do **not** try to make removal cache-only: a demotion (member stays in the group) is indistinguishable from a deletion at the roster-diff level, so suppressing the broadcast would silently drop the self-healing the spec requires for role changes. The only cost is one redundant roster broadcast alongside `XGrpMemDel` on the rare deletion of a privileged member — and even there the broadcast is not waste, since it self-heals the privileged-set side if the `XGrpMemDel` was lost. (This supersedes an earlier "cache-only on deletion" idea, which could not be implemented without either a wire flag or a fragile demotion-vs-deletion diff.) + +**On relay add:** the owner sends the current roster to the new relay so it can serve joiners. + +**Joiners:** the relay forwards the cached roster at join (1.5). + +**Short offline gaps** are covered by ordinary queued delivery: the role-change roster broadcast sits in the member's SMP queue (FIFO) ahead of any later moderator events, so it is processed first on reconnect. + +**Quota-blocked catch-up.** A member offline long enough to fill its queue causes the relay to be quota-blocked — the broadcast may never have been enqueued, so naive queueing would leave the member without the current roster, rejecting moderator events indefinitely. Fix: when the queue drains, the relay **sends the current cached roster ahead of the resumed backlog**, so the member holds the current privileged set before processing the events it couldn't verify. + +The hook is confirmed: QCONT is delivered to the **sender** when the recipient drains (`simplexmq Agent.hs:3402`), and the relay receives it per subscriber in the group-member connection handler (`Subscriber.hs:1215` — `continueSending` + `sendPendingGroupMessages user gInfo m conn`, with `gInfo`/`m`/`conn` in scope; a relay→subscriber connection is a group-member connection). Implementation must ensure **roster-first ordering** relative to both the agent-level `continueSending` flush and the re-driven delivery tasks, and gate the extra send on a per-member "delivered roster version" so it fires only when the member is behind. + +The roster is **never** delivered through the profile-dissemination prepend. That path (`member_relations_vector` → `XGrpMemNew`) carries **profiles** only; with the gate removed (1.5), a privileged member's profile disseminates through it like any other member's, but only after the roster has established their key/role. Profile via prepend, key/role via roster — orthogonal, no double-prepend. + +## 1.7 Relay-side cache (the one new storage pattern) + +Relays already forward signed bytes verbatim (`encodeFwdElement` `Batch.hs:106`, `verifiedMsgParts` `Protocol.hs:1445`; `messages.msg_chat_binding` + `msg_signatures`; reconstructed in `toTask` `Delivery.hs:154`). What does **not** exist is "store the latest roster and re-emit to joiners" — `sendHistory` (`Internal.hs:1207`) reconstructs content and does *not* preserve signatures, so it is not a template. + +Add a small per-group cache holding the latest signed roster message bytes, plus the roster `version` as a **separate column** alongside them (so the relay compares versions without re-parsing the blob). On receiving `XGrpRoster` from an owner the relay: verifies the owner signature; **checks `version` strictly greater than the cached version** (lower → reject as rollback; equal → idempotent no-op); then updates its own member-role records, overwrites the cache + stored version, and (for a role-change-origin roster) creates a delivery task to all current members. On join, it forwards the cached bytes verbatim. + +The relay-side version check protects an **honest** relay's cache from being rolled back by a replayed signed roster — which in turn protects every joiner that relay serves. It does not constrain a **malicious** relay (it controls its own cache); that remains the documented new-joiner residual gap (1.3), bounded by the member-side check (1.3) and the link version anchor. + +## 1.8 Races and tests + +- **Promotion vs. action ordering.** A newly-promoted mod could act before its roster reaches a recipient ⇒ `RGEMsgBadSignature`. Covered for MVP by causal ordering (the roster is broadcast at promotion, before the mod learns of and acts on it), QCONT catch-up (item 7), and recipient tolerance (a rejected first action is re-sent; the next roster repairs trust). The fuller fix — the **roster-specific prepend** (prepend the cached signed roster ahead of a privileged member's forwarded action for recipients below the current version, reusing the item-7 delivered-version tracker, distinct from the `XGrpMemNew` profile prepend) — touches the hot per-recipient delivery loop that carries every forwarded message, so it is **deferred to a focused, separately-tested pass** rather than shipped untested. +- **Multi-owner roster signed by an unknown owner** (owner added after the recipient fetched the link): recipient cannot verify ⇒ buffer/refetch link. Cannot occur for single-owner MVP; flag for v7. +- **Roster vs. profile-update concurrency:** benign (different fields); verify the roster's relations-vector handling does not clobber the profile `MRIntroduced` semantics they share. + +Tests: relay-fabricated moderator key is rejected (forgery); promotion delivers a verifiable key; demotion via roster + `XGrpMemRole` reconciles; removed privileged member does not reappear for a new joiner; replayed older roster rejected by existing members; TOFU key-change rejected; batch `APIMembersRole` emits roster + `XGrpMemRole` correctly; self-healing after a dropped role event. + +## 1.9 Key anchors for Section 1 + +`ChatMsgEvent` `Protocol.hs:422`; `requiresSignature` `Protocol.hs:1231`; `groupMsgSigning` `Internal.hs:1962`; `withVerifiedMsg` `Subscriber.hs:3461`; `xGrpMemNew` `Subscriber.hs:2957`; `xGrpMemIntro` `Subscriber.hs:3015`; `introduceInChannel` `Internal.hs:1165`; `getGroupModerators` `Store/Groups.hs:1190`; `memberInfo` `Internal.hs:1187`; `createLinkOwnerMember` `Store/Groups.hs:3072`; `GroupKeys` `Types.hs:462`; `member_relations_vector` machinery in `Types/MemberRelations.hs` (`MemberRelation`, `MRIntroduced`, `IDSubjectIntroduced`, `setNewRelations`); forwarding `Batch.hs:106` / `Protocol.hs:1445` / `Delivery.hs:154`. New columns go in a **new tail migration** (`M20260222_chat_relays` is the *pattern* for relay group columns but is not the tail — never edit an applied migration; `M20260525_member_removed_at` is the current tail). + +--- + +# Section 2 — Joiner role on the signed profile + +Today the relay derives a joiner's role from `channelSubscriberRole` (`Controller.hs:161`, default `GRObserver` `Chat.hs:119`), a global config — so relays can disagree and the owner cannot set it per group. Move it onto the owner-signed profile. + +## 2.1 Types and helpers + +- Add `joinerRole :: Maybe GroupMemberRole` to `PublicGroupProfile` (`Types.hs:798`). No migration: JSON derives via `deriveJSON defaultJSON` with `omitNothingFields = True`, so `Nothing` is omitted on encode and a missing field decodes to `Nothing`. +- Add a `groupType` accessor and `isChannel` on `GroupInfo`/`GroupProfile`, and a resolver `joinerRoleFor :: GroupInfo -> GroupMemberRole` = `joinerRole` if set, else type-keyed default (`GTChannel → GRObserver`, `GTGroup → GRMember`, `GTUnknown _ → GRObserver`). Reuse the existing `publicGroupEditor`/`memberRole'` (`Types.hs:499`/`506`); do **not** introduce a profile-side `memberRole'` (name collision). + +## 2.2 Replace the global config + +Switch every `channelSubscriberRole` reader to `joinerRoleFor gInfo` and delete the config: `Controller.hs:161`, `Chat.hs:119`, `Commands.hs:2053`, `Commands.hs:2546`, `Subscriber.hs:3248`, `Subscriber.hs:4019`. Verify no out-of-tree consumer reads it. + +## 2.3 Command, preferences, defensive refusal + +- `APINewPublicGroup` (`Controller.hs:526`, handler `Commands.hs:2495`) gains `groupType` (default `GTChannel`) and optional `joinerRole` (default `joinerRoleFor` of the type); both written onto the constructed profile (today hardcodes `groupType = GTChannel` at `Commands.hs:2538`). +- Parameterize the channel-prefs parser by `GroupType`: Channel keeps its override (`support = OFF`); Public group and `GTUnknown` use secret-group defaults (member-to-moderator escalation is expected). Do not duplicate the parser — parameterize it. +- `directMessages` stays ON by inheritance but is dormant in any relay-mediated group; hide its toggle when `useRelays` and refuse `xGrpDirectInv` defensively when `useRelays'` (`Subscriber.hs:3321`, currently ungated): emit `messageError`, create no contact. + +## 2.4 Compatibility + +Existing channels: no `joinerRole` ⇒ falls back to `GRObserver` for `GTChannel`. No data migration. Older relays without this change resolve the joiner role from their global config — warn the owner at create time if a selected relay's chat version is below the public-groups version (soft warning, not a block). + +--- + +# Section 3 — Backend tests + +Public-group helpers paralleling the channel helpers, plus: + +1. Member posts; all members receive it (no "unknown member" lines). 2. Multi-author session: no "unknown member" anywhere. 3. Member edit/delete/react forwarded to all. 4. `xGrpDirectInv` refused under `useRelays` (no contact created); repeat for Channel. 5. Blocked member's messages not forwarded. 6. Multi-relay delivery with cross-relay dedup. 7. History on join. 8. `asGroup=true` from a non-owner rejected. 9. Receipts disabled above the member limit. 10. Older client refuses `groupType = "group"` (needs-newer-version). 11. Incognito member posting attributes the incognito profile. 12. `joinerRole` propagates and defaults correctly (Channel→observer, Public group→member; absent→type default). Plus the roster tests in 1.8. + +--- + +# Section 4 — Clients (iOS, then Kotlin) + +## 4.1 Audit `useRelays` vs `isChannel` (structural commit, on its own) + +~70–75 sites per platform branch on `useRelays` as a proxy for "is a channel." Split per a mechanical rule and land as a pure structural commit (no behavior change in the same diff): + +- **Transport** (keep `useRelays`): link/relay management, owner-can't-leave-own-relay-group, relay-status indicator, incognito flag, typing-state gating, member-DM-affordance suppression. +- **Governance** (switch to `isChannel`): titles, "subscribers" vs "members" framing, "Channel preferences" labels, channel-style vs group-style member display. + +## 4.2 Model and behavior + +- Model: add `group` arm to `GroupType` (with serializers); `joinerRole`, `groupType`, `isChannel` accessors. Authoritative role resolution stays in Haskell; clients use it for display. +- **Narrow the existing refusal:** PR #7009 (merged to `stable`) added `GLPUpdateRequired` for `groupType /= GTChannel` (`Controller.hs:1051`, `Commands.hs` `unsupportedGroupType`). Change it to refuse only `GTUnknown _`; `GTGroup` proceeds to a public-group join. +- Create flow: one view with a Channel / Public-group segmented control (default Channel) driving the title, link-step label, success screen, and two API params (`groupType`, `joinerRole` = observer for Channel, member for Public group — no role picker in MVP). Hide `directMessages` in create prefs when `useRelays`. Render the threat-model note below the title for Public groups (text in Section 5). +- Suppress the member-tap "send direct message" affordance in any relay-mediated group. +- Members view shows the relay-known roster; header "subscribers" (channel) vs "members" (public group). No filtered view in MVP. +- Strings/icons: ~5–10 `_public_group` string keys mirroring channel forms; reuse `group_members_*` for "members" framing; a distinct Public-group icon (pending design). Kotlin-only: chat-list filter chips place Public groups in the "groups" bucket. + +Platforms ship independently (API defaults are backward compatible). + +--- + +# Section 5 — Threat model, docs, release + +Fold into `channels-overview.md` (public groups inherit the entire channel threat model; deltas only): + +- **A relay can fabricate content as any member** (channels: only as owners). Content (`XMsgNew`/`Update`/`Del`/`React`) is unsigned by design for deniability (`requiresSignature` lists roster/admin events only); broader blast radius in public groups. Detectable via cross-relay consistency. Mitigation is the future opt-in content signing on the channel roadmap; the create-flow note states the trade-off ("a malicious relay could change or fabricate messages from any member — pick relays you trust, or use a secret group for peer-to-peer integrity"). +- **Roster rollback for new joiners** (1.3): documented bounded delta + future mitigations. +- Unchanged: relay cannot impersonate an owner or substitute the profile (signed events, validated entity ID); `joinerRole` and the privileged roster are owner-signed, so the relay cannot unilaterally change a joiner's default role or fabricate a moderator. + +Document `XGrpRoster` (event, signing, versioning, TOFU, delivery) in `channels-protocol.md`. Bump the chat protocol version (the public-groups version that gates `GTGroup` and `joinerRole`). Release notes include the relay-fabrication line. + +--- + +# Sequencing + +1. **Section 1 — privileged roster** (backend). The core; gates the rest of the value. Land the gate-removal/roster-check and the event together so no half-applied trust window exists. +2. **Section 2 — joiner role on profile** (backend). Independent of Section 1. +3. **Section 3 — backend tests** (alongside 1–2). +4. **Section 4 — clients**: audit (structural) first, then iOS, then Kotlin. +5. **Section 5 — docs/version/release** with the backend release. + +Backend (1–3) gates the clients. iOS and Kotlin are independent of each other. + +--- + +# Out of scope (deferred) + +- **Member-to-member DMs in relay-mediated groups.** Prohibited here (client affordance suppressed, receive-path refusal, relay does not forward `XGrpDirectInv` — so no relay-visible DM graph). A future plan must re-derive the threat model: relay-forwarded DMs would expose (sender, target, time) metadata; relay-blind rendezvous via per-member queues is the privacy-preserving alternative. +- **`memberAdmission` on relay-mediated join** (hardcoded `GAAccepted` bypasses review/captcha — generic relay-groups gap). +- **Roster filter/pagination in the members view** for very large groups. +- **Multi-owner** roster signing/verification and owner promotion via link `OwnerAuth` (v7); **opt-in content signing** (v7 roadmap); **full anti-rollback for new joiners** (link-version hard gate). diff --git a/plans/2026-06-01-roster-members-multipart.md b/plans/2026-06-01-roster-members-multipart.md new file mode 100644 index 0000000000..aca98c4698 --- /dev/null +++ b/plans/2026-06-01-roster-members-multipart.md @@ -0,0 +1,220 @@ +# Roster: regular members + larger rosters via inline file + +Date: 2026-06-01 (revised). Extends Section 1 of `2026-05-26-public-groups-via-relays-unified.md`. + +> Anchors below were re-verified against `f/public-groups-members-in-roster` **after** PR #7036 (`core: signed XMember in public group`, commit `0773ccd05`) merged in. Most line numbers shifted; the header-fits check uses `maxEncodedMsgLength = 15602` (now `Protocol.hs:905`). Confirm before editing. + +## Reconciliation with PR #7036 (merged into this branch) + +PR #7036 landed things this plan predates. Read this section first — it changes the relay flow the plan builds on. + +**Renames (the plan's old names no longer exist — grep will miss them):** + +| Was | Now | Location | +|---|---|---| +| `forwardCachedRoster` | `forwardGroupRoster` | `Internal.hs:1172` | +| `setCachedGroupRoster` | `setGroupRoster` | `Store/Groups.hs:1415` | +| `getCachedGroupRoster` | `getGroupRoster` | `Store/Groups.hs:1428` | +| `setRelayLinkAccepted` | `setRelayKey` (no longer sets relay status) | `Store/Groups.hs:1543` | + +"Cached roster" is now "saved roster" throughout; the `roster_msg_*` columns and the `roster_blob` this plan adds are unchanged in intent. + +**Roster version baseline is now `Just 0`, not NULL, for relay groups.** `createNewGroup` initializes `roster_version = Just (VersionRoster 0)` for `useRelays` groups (`Store/Groups.hs:365, 427`), and an old channel materializes `0` the first time a relay connects (`Subscriber.hs:905-910`). Consequences: the first promotion bumps `0 -> 1` (not NULL -> 0); the owner and already-onboarded members/relays compare against a real `0`, **but a relay's own `roster_version` is still NULL the first time it receives v0** — applying v0 from NULL is exactly what lets it ack and become publishable (verified: today's `maybe False (newVer <=) (rosterVersion gInfo)` at `Subscriber.hs:3207` applies v0 only because the relay is at `Nothing`; `Just 0` would reject it and the relay would hang `RSInvited`). So the multipart version guards MUST be `Maybe`-comparisons that treat `Nothing` as below `0` (spelled out under *Header handler* and *Completion*); and the **empty roster (v0)** must round-trip through the header+blob path (a 2-byte blob: `Word16` count `0`, one chunk, `chunkSize >= fileSize` -> `RcvChunkFinal` on chunk 1). The empty/small blob is the *common* case for relay onboarding, not an edge case. + +**NEW relay roster-ack handshake (`XGrpRosterAck`) — this plan MUST integrate it.** PR #7036 added `XGrpRosterAck :: VersionRoster -> Maybe Text` (`Protocol.hs:499`; tag `x.grp.roster.ack`; NOT in `requiresSignature` — it rides the relay's authenticated connection). Flow: + +- On relay connect (`GCInviteeMember` + `isRelay`) the owner **always** sends the current roster via `sendGroupRosterToRelay`, and the relay stays `RSInvited` (**unpublishable**) until it acks (`Subscriber.hs:900-910`). +- The relay applies the roster in `relayApplyRoster` and, **only while its own status is `RSAccepted`**, sends `XGrpRosterAck author newVer Nothing` (or an error string) — `Subscriber.hs:3210-3221`, `sendRosterAck` at `3276`. +- The owner's `xGrpRosterAck` handler (`Subscriber.hs:3279-3297`) transitions the relay `RSInvited -> RSAccepted` (and publishes via `setGroupLinkDataAsync`) on a version-matching success ack, or `RSInvited -> RSRejected` on error. + +Impact on the multipart design (a REQUIRED change, not just a rename): under this plan the header only *starts* a transfer, so on a relay the **apply, `setGroupRoster`, the ack, AND the broadcast all move to blob completion**, not header receipt. The relay becoming publishable now **gates on the blob transfer completing**: header -> chunks -> verify digest -> `processRoster` + `setGroupRoster` -> (`relayOwnStatus == RSAccepted`) `sendRosterAck` -> broadcast. This is the desired fail-safe (an unpublishable relay can't serve a half-applied roster), but it puts owner->relay blob delivery on the relay-onboarding critical path, not just self-healing. The error branch MUST still ack-with-error (digest mismatch / parse failure -> `sendRosterAck author newVer (Just "...")`) so the owner marks the relay `RSRejected` instead of leaving it hung at `RSInvited`. The current `relayApplyRoster` to fork is `tryAllErrors (setRoster sm)`, where `setRoster` = `processRoster` + `setGroupRoster` (`Subscriber.hs:3205-3228`); the multipart version splits this across header (write `roster_pending_*`) and completion (run `setRoster` + ack + broadcast). + +## Goal + +Let owners promote channel subscribers to **regular members** who can post, and carry more named members than fit one message. + +The JSON roster already exists (event, signing, relay cache, TOFU apply, broadcast, join forward, QCONT). This plan **widens the roster set to include plain members and changes the delivery** to a binary blob over the inline file transfer; the apply logic (`processRoster`) is reused. + +The member list moves out of the `XGrpRoster` message into a binary blob sent over the existing inline file transfer. `XGrpRoster` becomes a small signed header (version + the blob's size and digest). + +## Roster set: the promoted set {member, mod, admin} + +Owners stay on the link, never in the roster. Two edits, then every gate follows. + +**1. Widen `isRosterRole`** (`Internal.hs:1237`) to `{GRMember, GRModerator, GRAdmin}`. Every call site wants the promoted set, so this single edit covers: + +- `validateGroupRoster` filter (`Internal.hs:1243`) — fixes the bug where member entries are dropped. +- `buildGroupRoster` filter (`Internal.hs:1255`). +- promotion gates / cap / trigger / counts (`Commands.hs:2737, 2739, 2746, 2762, 2763, 2768`); update the cap error text at `2740`. +- owner-remove roster refresh (`Commands.hs:2888`, guarded by `anyPrivilegedRemoved` computed from `isRosterRole` at `2899`) — so removing a plain member, not just a mod/admin, refreshes the roster. +- receive gates: `xGrpMemNew` (`Subscriber.hs:3011/3026/3045`) and `xGrpMemRole` owner-only (`3185`). +- **join key-proof gate (NEW in PR #7036, `Subscriber.hs:1620`, `memberJoinRequestViaRelay`)**: a join claiming a `memberId` already roster-established as `isRosterRole` must prove possession of the pinned key (signature + `memberPubKey` match + `viaRelay == this relay's memberId`). Widening to members **extends this proof to promoted members** — a promoted member re-connecting through a relay must sign its `XMember` with the roster-pinned key. This is correct and desirable (it is the receive-side counterpart of the promote-time key invariant in *Known limitations*), and it composes with PR #7036's `acceptGroupJoinRequestAsync existingMem_` path that attaches the connection to the existing roster record. Confirm the promoted member signs its join `XMember` (it does — `encodeXMemberConnInfo`, `Internal.hs`). + +**2. Split the role query.** `getGroupRosterMembers` (`Store/Groups.hs:1215`) currently serves two now-diverging needs: + +- **Build / revert** wants the promoted set. Redefine `getGroupRosterMembers` to `member_role IN (GRMember, GRModerator, GRAdmin)` (current members). Callers: `bumpAndBroadcastRoster` (`Internal.hs:2175`), `sendGroupRosterToRelay` (`2188`), and the `processRoster` revert set `currentPriv` (`Subscriber.hs:3245`). Build and revert MUST be the same query, or a dropped member is never reverted. +- **`introduceInChannel`** (`Internal.hs:1188`) wants only the moderation set (mod+admin). Widening it would announce every joiner to every member and introduce every member to every joiner (traffic + anonymity blowup). Reuse the existing `getGroupModerators` (`Store/Groups.hs:1204-1209`, returns mod+admin+owner) rather than adding a function: keep `getGroupOwners` for the owner-first intro, and take mod+admin as `getGroupModerators` minus owners. **Re-apply `filter memberCurrent`** — `getGroupModerators` does NOT filter current members (unlike the old `getGroupRosterMembers`), so without it a removed or left moderator would be introduced to joiners. Members are learned from the roster blob, not introductions. + +**Owner-only (confirmed decision).** Only the owner changes any roster role. The alternatives were considered and rejected for v1 — letting a mod/admin set member roles would need either the owner co-signing rosters from a mod/admin (owner round-trip + load) or a separate roster-signing key trusted from mod/admin (broader trust surface) — so owner-only keeps the single owner-key trust anchor. + +**Leave and owner-remove differ.** This plan **removes** the `xGrpLeave` roster-bump block (`Subscriber.hs:3439`): since `isRosterRole` is widened, it would otherwise fire `bumpAndBroadcastRoster` on every plain-member leave. So a member **leave** does NOT bump the roster — the leave is the membership axis (`XGrpLeave` neutralizes the member on the relay). An owner **remove** (`APIRemoveMembers`) DOES still bump via `bumpAndBroadcastRoster` (`Commands.hs:2888`, widened to cover plain members). `bumpAndBroadcastRoster` thus stays only for promotion (`APIMembersRole`) and owner-remove. + +## Wire: signed header + unsigned blob + +**Authoritative metadata is in the signed header.** `version`, blob `fileSize`, and `fileDigest` all live in the owner-signed `XGrpRoster`; the unsigned `BFileChunk`s carry no authoritative metadata. (This is why "total parts in the unsigned part", an earlier review question, is a non-issue here.) + +- **Header**: `XGrpRoster { version :: VersionRoster, fileInv :: InlineFileInvitation }`, JSON, signed, forwarded. `InlineFileInvitation { fileSize, fileDigest :: FD.FileDigest }` is a lean `FileInvitation` (no name/connReq/inline/descr; always inline). Tiny; fits `maxEncodedMsgLength` (15602, `Protocol.hs:905`). +- **Blob**: the binary member list. `RosterMember { memberId, key, role, privileges :: Word16 }` — drop `name`, add `privileges` (reserved: always `0`, parsed and ignored in v1). ~60 B/entry. Members get a placeholder name from `nameFromMemberId`; real profiles arrive on first post. +- **Serializer/parser**: a binary codec for the blob (a `Word16`-count-prefixed `[RosterMember]`); `RosterMember` becomes binary-only. Full code in *Blob format* below. Owner serializes → digest → chunks; receiver concatenates chunk bytes → verifies the digest → parses. +- **Cap** `maxGroupRosterSize` → **256** (tunable). Enforce at promotion over the promoted set (`Commands.hs:2739`, via the widened predicate); the receive-side entry-count bound is the parser alone (`rosterBlobP`'s `n > maxGroupRosterSize`); reject a signed `fileSize > cap × max-entry-size` before creating a file. Roster files are exempt from the inline `offer/receiveChunks` ceiling (at 256 ≈ 15 KB the blob is about one `fileChunkSize` chunk; the multipart path handles two if role words push it over). + +**Type changes.** + +- `GroupRoster` (`Protocol.hs:372-376`): `{version, roster :: [RosterMember]}` → `{version, fileInv :: InlineFileInvitation}`. It stays JSON (the signed header), so `InlineFileInvitation` needs a JSON instance; update its stale doc comment ("Owner-signed snapshot of the privileged (moderator/admin) set"). +- `RosterMember` (`Protocol.hs:378`): drop `name`, add `privileges :: Word16`; remove `deriveJSON` (`Protocol.hs:812`) — binary-only now — and add the `Encoding` below. `buildGroupRoster`'s constructor (`Internal.hs:1255`, currently `name = memberShortenedName m`) drops the `name` field; the consumer side already maps to `nameFromMemberId`. +- `validateGroupRoster` (`Internal.hs:1241-1242`): was `GroupRoster -> GroupRoster` over `.roster`; now `[RosterMember] -> [RosterMember]`, run on the parsed blob. + +### Blob format (serializer / parser) + +`RosterMember` is **binary-only** (carried in the blob, never in a JSON message) and gets the `Encoding` below. `MemberKey` (`Types.hs:972`, only `StrEncoding`) and `GroupMemberRole` (`Types/Shared.hs:33`, only `TextEncoding`) lack a binary `Encoding`: `MemberKey` delegates to the underlying `PublicKey` (`Crypto.hs:568`), and the role delegates to its canonical `TextEncoding` (the same `"member"/"moderator"/"admin"` form JSON and the DB use — single source of truth; `GRUnknown` round-trips). + +```haskell +-- MemberKey gains a binary Encoding (it only had StrEncoding); delegate to the Ed25519 key. +instance Encoding MemberKey where + smpEncode (MemberKey k) = smpEncode k + smpP = MemberKey <$> smpP + +-- General instance (belongs beside GroupMemberRole's TextEncoding in Types/Shared.hs, not here). +instance Encoding GroupMemberRole where + smpEncode = smpEncode . textEncode + smpP = maybe (fail "bad GroupMemberRole") pure . textDecode =<< smpP + +-- Tuple encoding (Encoding (a,b,c,d), Encoding.hs:192), as GrpMsgForward / FwdSender do. +instance Encoding RosterMember where + smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges) + smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP + +-- Blob = Word16 count (NOT smpEncodeList: its 1-byte count overflows at the 256 cap) followed +-- by that many entries. This is the byte sequence the digest is computed over and verified +-- against before parsing. +encodeRosterBlob :: [RosterMember] -> ByteString +encodeRosterBlob ms = smpEncode (fromIntegral (length ms) :: Word16) <> B.concat (map smpEncode ms) + +rosterBlobP :: Parser [RosterMember] +rosterBlobP = do + n <- fromIntegral <$> smpP @Word16 + when (n > maxGroupRosterSize) $ fail "roster: too many entries" + A.count n smpP +``` + +- **Owner**: `encodeRosterBlob` over the promoted set → `FileDigest` (SHA-512, as the file machinery computes it, `LC.sha512Hash`) → chunk; the digest goes in the signed `XGrpRoster` header. +- **Receiver**: concatenate chunk bytes → verify the digest (S1, over plaintext) → `parseAll rosterBlobP` (consume all input; reject trailing bytes). Parsing runs only after the digest matches, so the bytes are owner-attested; the `n > maxGroupRosterSize` guard and `parseAll` are defensive against a buggy/garbled blob. +- **Per-entry layout**: `memberId` (1-byte len + id) + `key` (1-byte len + Ed25519 pubkey) + role (1-byte len + role word, e.g. `member` = 7 B) + `privileges` (2 bytes) ≈ ~60 B/entry. The file-transferred blob has no tight size budget, so canonical text is fine. +- `privileges` is reserved: serialized as `0`, parsed and ignored in v1. + +## Delivery: send → header → chunks → completion + +### Owner send + +`bumpAndBroadcastRoster` and `sendGroupRosterToRelay` build the blob (`buildGroupRoster` over the widened query), compute its `FileDigest`, send the `XGrpRoster` header, then send the blob as `BFileChunk`s against that message's `shared_msg_id`. + +`sendFileInline_` reads from a file, so add a send-from-bytes variant (shared with the relay re-serve). The owner's own version bump stays as today (in `bumpAndBroadcastRoster`, `Internal.hs:2176`) — the owner is the source of truth; "bump only at completion" is a receive-side rule. + +### Header handler (`xGrpRoster`, member and relay) + +The header no longer applies anything — it starts a transfer. It only writes `roster_pending_*`; it never writes `roster_version` or the live `roster_msg_*`. + +- **Short-circuit** unless `version > max(roster_version, roster_pending_version)` — strictly greater than both applied and pending — before creating a file. These are **`Maybe` comparisons: `Nothing` (un-materialized version) counts as below `0`** (mirror today's `maybe False (newVer <=) …`, `Subscriber.hs:3207`), so a relay's first receipt at NULL **applies** v0 while a re-receive at `Just 0` short-circuits — the v0 onboarding depends on this. + - Why both: the QCONT re-serve is unconditional, so the relay may re-forward a still-cached v5 while a member is mid-receiving v6 (applied 4). Compared only to applied, v5 > 4 would supersede v6, then the arriving v6 chunks fail the v5 digest → stuck. + - Why never bump here: a header-time bump makes the genuine blob complete as an equal-version no-op, leaving the receiver at `vN` with `v(N-1)` data. +- **Create the rcv-file** with `cryptoArgs = Nothing` (see Security), `file_type = roster`, `chat_item_id` NULL, `shared_msg_id` = the header's id. Accept it via `startRcvInlineFT` (chat-item-free), not `acceptRcvInlineFT`, so chunk 1 isn't rejected on `RFSNew`. +- **One in-flight per group is automatic**: the single `groups` row makes `roster_pending_*` single-valued, and there is one `(group_id, file_type = roster)` file. A duplicate header is idempotent. A version greater than both applied and pending supersedes: `UPDATE roster_pending_*` and delete the existing roster file (cleanup below), then create the new. + +### Chunks + +The header is enqueued before chunk 1 (per-connection FIFO). + +**Reset-on-chunk-1** (decision 4): if chunk 1 arrives with partial chunks, discard and restart so relay restart / re-subscribe / QCONT can re-drive from the start. Discarding MUST (GAP 3): + +- delete the `rcv_file_chunks` rows, +- truncate/remove the on-disk file, and +- evict its handle from the `rcvFiles` map (`closeFileHandle`). + +`appendFileChunk` opens in AppendMode and caches the handle (`Internal.hs:1781`, handle at `1794`), so clearing only the rows would append after the stale bytes and corrupt the blob (digest fails — the stuck state decision 4 avoids). + +**Orphaned chunk**: a roster `BFileChunk` matching no in-flight `(group_id, shared_msg_id, file_type = roster)` file is **ACKed and ignored**, never errored (the version is already applied or superseded). This is how an up-to-date member tolerates the unconditional re-serve: the re-served header short-circuits (no file), then its chunks arrive with no transfer in flight. Distinct from reset-on-chunk-1, which fires only when partial chunks exist. + +### Completion (on `RcvChunkFinal`) + +1. Verify the assembled file's digest against `roster_pending_digest`. On mismatch, discard (delete the file, clear `roster_pending_*`); do not apply or bump. +2. **Version guard**: apply only if `roster_pending_version > roster_version` (same `Maybe` semantics — a `Nothing` applied version counts as below `0`, so a first v0 completion from NULL applies). A stale/out-of-order completion is rejected, not applied as a downgrade. +3. Parse → `validateGroupRoster` → `processRoster` (TOFU keys, role updates, revert absent promoted members, role-change items; pass `nameFromMemberId` where it used the entry name). + +In **one transaction**: `processRoster` → set `roster_version = roster_pending_version` → set `roster_blob` → clear `roster_pending_*` → delete the file. A **relay** also promotes the pending signed-header columns into the live `roster_msg_*` (this is what `setGroupRoster` writes today at header time — it moves here) and applies to its own records, then **sends the roster ack and broadcasts** (below). So a joiner never sees a live header at `vN` paired with a blob at `vN-1`. + +**Relay ack at completion (PR #7036 integration).** The relay's `XGrpRosterAck` (previously sent in `relayApplyRoster` at header receipt) moves to completion, gated exactly as today on `relayOwnStatus gInfo == Just RSAccepted`: on a successful completion send `sendRosterAck author roster_pending_version Nothing`; on digest-mismatch or parse failure send `sendRosterAck author roster_pending_version (Just "...")` so the owner marks the relay `RSRejected` rather than leaving it `RSInvited` forever. A relay therefore becomes publishable only after the full blob arrives and applies — the desired fail-safe, but it makes owner→relay blob delivery part of the relay-onboarding path, so it MUST be reliably driven (see *Owner send* and *Relay re-serve*; for a freshly-connecting relay the owner drives it via `sendGroupRosterToRelay`, including the empty v0 blob). The `relayOwnStatus == Just RSAccepted` gate is ported unchanged but now evaluated at completion rather than header receipt — confirm `relayOwnStatus` cannot change across the header→completion window (it shouldn't: a relay can't reach `RSActive` before acking, since the ack is what publishes it). + +The version guard plus the per-version `shared_msg_id` keying are what make the design correct; the short-circuit and one-in-flight-per-group are optimizations. + +### Relay re-serve (broadcast / join / QCONT) + +Per recipient, forward the signed header (as `forwardGroupRoster` does today, `Internal.hs:1172`) AND re-send the blob as `BFileChunk`s from `groups.roster_blob` (the send-from-bytes variant). An incoming `BFileChunk` returns no delivery task (`Subscriber.hs:1089`), so the blob send is driven here. + +**No per-member version gate in v1 (GAP 2).** QCONT/SENT re-forwards the saved roster unconditionally today (`Subscriber.hs:1143`, `1237`), and no per-member delivered-version tracker exists in the tree — this plan adds none. So a re-serve re-sends the whole blob on every drain; at cap 256 that is ~15 KB — one (occasionally two) `BFileChunk`s per drain — acceptable. + +It is safe because: an up-to-date member short-circuits the header and ACK-ignores the orphaned chunks; a stale (≤ pending) re-forward mid-transfer is a no-op via the short-circuit; and the completion version guard rejects any stale completion. + +If the cap is later raised so the blob spans many chunks, add a per-member `delivered_roster_version` column (read on QCONT/join/broadcast, written on confirmed delivery) and re-serve only when behind — future work. + +### Supersede / cancel cleanup + +Cleanup spans ALL of these — miss none: + +- `files`, `rcv_files`, `rcv_file_chunks`, +- the on-disk file and its `rcvFiles` handle (`closeFileHandle`), +- the `roster_pending_*` columns on `groups` (set NULL). + +## File-machinery changes (only these) + +- **Lookup**: add `files.shared_msg_id`; resolve roster chunks by `(group_id, shared_msg_id, file_type = roster)`. Leave `getGroupFileIdBySharedMsgId` (`Store/Files.hs:310`, chat-item JOIN) for normal files; branch on `file_type` / `chat_item_id IS NULL`. +- **Fork the three receive sites that call `getChatItemByFileId`** (they throw with no chat item): + - `startReceivingFile` (`Internal.hs:827`, reached on chunk 1) — skip the chat item + `CEvtRcvFileStart`. + - `receiveFileChunk` `RcvChunkFinal` (`Subscriber.hs:1329`) — replace with the completion path above. + - `FileChunkCancel` (`Subscriber.hs:1313`) — delete file + drop in-flight state, no chat item. +- **Cleanup keyed on `group_id`** (not chat items): `getGroupFileInfo` INNER-JOINs `chat_items`, so group delete (`Commands.hs:1270`) and clear (`1305`) skip roster files; the DB row cascades on group delete but the on-disk file leaks. Add a roster-file cleanup for delete/clear, cancel, and supersede. + +## Storage / migration + +In-flight state lives on `groups` (mirroring the live saved roster) and `files` (located by `shared_msg_id`) — no join table. These columns go into the in-progress **`M20260602_group_roster`** migration (already part of this work, not yet merged — so it's editable, not an applied migration), SQLite + Postgres; tests regenerate the schema files. + +| `groups` column(s) | Holds | Lifecycle | +|---|---|---| +| `roster_version` *(kept)* | applied version | bumped at completion | +| `roster_msg_*` *(kept)* | live signed header (was full JSON) | relay forwards verbatim; promoted from pending at completion | +| `roster_blob` *(new)* | durable completed blob | written at completion; relay re-serves it | +| `roster_pending_version`, `roster_pending_digest` *(new)* | in-flight version + digest | set on header receipt; cleared at completion | +| `roster_pending_msg_*` *(new, relay-only)* | in-flight signed header | set on header receipt; promoted to live at completion (NULL on members) | + +The kept `roster_msg_*` columns stay the relay's verbatim-forward source and trust anchor: `forwardGroupRoster` re-forwards them so the joiner verifies the owner signature, and the digest inside authenticates the unsigned blob. + +`files` adds `shared_msg_id` and `file_type`. The in-flight transfer is the `files` / `rcv_files` / `rcv_file_chunks` rows with `(group_id, file_type = roster)`. + +## Security + +- **Owner-signed header**: assert `memberRole' author == GROwner` (`Subscriber.hs:3198`); keep `XGrpRoster_` in `requiresSignature`. +- **Integrity is entirely the digest** (S1): verify the assembled **plaintext** blob against the owner-signed `fileDigest` at completion. Hence `cryptoArgs = Nothing` — a set cryptoArgs makes `appendFileChunk` re-encrypt the file in place (`Internal.hs:1801`), so the on-disk bytes would be ciphertext and the check would fail. A corrupted chunk fails the digest and the roster is rejected. +- **TOFU** key pinning per `memberId` unchanged (different key for a known id → keep the trusted key). +- **Rollback (S2)**: the signature binds `publicGroupId + version` and the digest binds the blob to that header, so cross-group/version substitution stays blocked. But the blob now carries plain members, so a same-group replay of an old `(header, blob)` to a **new joiner** can re-introduce a removed poster or mask a demotion (existing members are protected by the version check). Update `channels-overview.md`. + +## Known limitations / out of scope + +- A malicious relay can withhold/corrupt chunks → the member stays on its last-applied roster (it can drop any message anyway); new-joiner rollback now covers plain members. +- A just-promoted member's first posts may show "unknown member" until the file arrives — self-healing. +- A member who **leaves** lingers in the roster blob until the next bump (this plan drops the leave-triggered refresh). Harmless: they have no relay connection and cannot post, so a new joiner sees only a ghost row; the owner's explicit remove (`APIRemoveMembers`) drops them. +- Out of scope: granting/enforcing `privileges`; member content signing; joiner-role-on-profile; clients. Do not couple the roster set to the joiner-role mechanism (decision 2) — it is the absolute `{member, mod, admin}`. + +## Tests (`tests/ChatTests/Groups.hs`) + +The roster tests now live under the `describe "promoted members roster"` block (PR #7036 moved them and added `testChannelAddRelayWithRoster`, which onboards a 2nd relay through the roster-ack handshake). Update those to header+file delivery — `testChannelAddRelayWithRoster` in particular now exercises the v0/empty-roster blob transfer feeding the relay ack, so it must drive the header+chunk(s) to completion before the relay acks. + +Then add: digest-mismatch blob rejected (no apply, no version bump) **and the relay acks-with-error → owner marks it `RSRejected`** (PR #7036 path); a relay does **not** ack / become publishable until the blob completes (ack moved to completion); member promotion enters the broadcast roster and can post; reset-on-chunk-1 recovery; superseding version cleans up the in-flight older file; version not bumped on header receipt or on a failed blob; `introduceInChannel` still mod+admin only (no member introductions); on-disk roster file cleaned on group delete/clear mid-transfer; non-owner promotion refused; a promoted member re-connecting through a relay is accepted only with a valid signed `XMember` over the roster-pinned key (the widened `memberJoinRequestViaRelay` gate). Existing mod/admin tests must still pass. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 22b97c83ee..f1effc2e6a 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -143,6 +143,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain + Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster else exposed-modules: Simplex.Chat.Archive @@ -304,6 +305,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain + Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ca4f3e8fcf..004d790844 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1289,6 +1289,8 @@ processChatCommand cxt nm = \case filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "deleteChat group" chatId $ do deleteCIFiles user filesInfo + -- the roster blob file has no chat item, so it is missed by getGroupFileInfo above + cleanupGroupRosterFile user gInfo (members, recipients) <- getRecipients gInfo let doSendDel = memberActive membership && isOwner msgSigned <- @@ -2050,9 +2052,9 @@ processChatCommand cxt nm = \case gVar <- asks random (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing hostMember <- maybe (throwCmdError "no host member") pure hostMember_ - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember - createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing + createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing Nothing cInfo = GroupChat gInfo Nothing void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo aci <- mapM (createItem welcomeSharedMsgId . CIRcvMsgContent) message @@ -2062,9 +2064,9 @@ processChatCommand cxt nm = \case pure $ CRNewPreparedChat user $ AChat SCTGroup chat ACCL _ (CCLink cReq _) -> do ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId - void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDDirectRcv ct - createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing + createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing Nothing cInfo = DirectChat ct void $ createItem Nothing $ CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ connRequestPQEncryption cReq void $ createFeatureEnabledItems_ user ct @@ -2081,11 +2083,11 @@ processChatCommand cxt nm = \case subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember gVar <- asks random (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo - aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing + aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing Nothing let chat = case aci of Just (AChatItem SCTGroup dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} _ -> Chat cInfo [] emptyChatStats @@ -2153,7 +2155,7 @@ processChatCommand cxt nm = \case -- create changed feature items (connecting incognito sends default preferences, instead of user preferences) lift . when incognito $ createContactChangedFeatureItems user ct ct' forM_ msg_ $ \(sharedMsgId, mc) -> do - ci <- createChatItem user (CDDirectSnd ct') False (CISndMsgContent mc) (Just sharedMsgId) Nothing + ci <- createChatItem user (CDDirectSnd ct') False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing toView $ CEvtNewChatItems user [ci] pure $ CRStartedConnectionToContact user ct' customUserProfile CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct' @@ -2246,7 +2248,7 @@ processChatCommand cxt nm = \case liftIO $ setPreparedGroupStartedConnection db groupId getGroupInfo db cxt user groupId forM_ msg_ $ \(sharedMsgId, mc) -> do - ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing + ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing toView $ CEvtNewChatItems user [ci] pure $ CRStartedConnectionToGroup user gInfo' customUserProfile [] CVRConnectedContact _ct -> throwChatError $ CEException "contact already exists when connecting to group" @@ -2756,34 +2758,45 @@ processChatCommand cxt nm = \case -- TODO [relays] possible optimization is to read only required members + relays g@(Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" - let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members + let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending, anyPrivilegedTarget, finalPrivilegedCount) = selectMembers members when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $ throwCmdError "can't change role of multiple members when admins selected, or new role is admin" when anyPending $ throwCmdError "can't change role of members pending approval" assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) + -- in relay groups the roster has a single signer, so only the owner may change moderator/admin roles + when (useRelays' gInfo && (isRosterRole newRole || anyPrivilegedTarget) && memberRole' (membership gInfo) /= GROwner) $ + throwCmdError "only the group owner can change moderator and admin roles" + when (useRelays' gInfo && isRosterRole newRole && finalPrivilegedCount > maxGroupRosterSize) $ + throwCmdError $ "the number of members, moderators and admins would exceed the limit of " <> show maxGroupRosterSize (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems - (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g currentMems + let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && (isRosterRole newRole || anyPrivilegedTarget) + rosterVer <- if doBumpRoster then Just <$> reserveRosterVersion gInfo else pure Nothing + (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g rosterVer currentMems + forM_ rosterVer $ \v -> broadcastRoster user gInfo v `catchAllErrors` eToView unless (null acis) $ toView $ CEvtNewChatItems user acis let errs = errs1 <> errs2 unless (null errs) $ toView $ CEvtChatErrors errs pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole, msgSigned} -- same order is not guaranteed where selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds - selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) - selectMembers = foldr' addMember ([], [], [], GRObserver, False, False) + -- anyPrivilegedTarget: a target currently moderator/admin; finalPrivilegedCount: + -- moderators + admins after the change (targets take newRole, others keep their role). + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool, Bool, Int) + selectMembers = foldr' addMember ([], [], [], GRObserver, False, False, False, 0) where - addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending) + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, privCount) | groupMemberId `elem` memberIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin anyPending' = anyPending || memberPending m - in - if - | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending') - | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending') - | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending') - | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending) + anyPrivTarget' = anyPrivTarget || isRosterRole memberRole + privCount' = if isRosterRole newRole then privCount + 1 else privCount + in if + | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount') + | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount') + | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount') + | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, if isRosterRole memberRole then privCount + 1 else privCount) changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) changeRoleInvitedMems user gInfo memsToChange = do -- not batched, as we need to send different invitations to different connections anyway @@ -2798,19 +2811,20 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> updateGroupMemberRole db user m newRole pure (m :: GroupMember) {memberRole = newRole} _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName - changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) - changeRoleCurrentMems user (Group gInfo members) memsToChange = case L.nonEmpty memsToChange of + changeRoleCurrentMems :: User -> Group -> Maybe VersionRoster -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + changeRoleCurrentMems user (Group gInfo members) rosterVer memsToChange = case L.nonEmpty memsToChange of Nothing -> pure ([], [], [], False) Just memsToChange' -> do - let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange' + let mKey m = if isJust rosterVer then MemberKey <$> memberPubKey m else Nothing + events = L.map (\m@GroupMember {memberId} -> XGrpMemRole memberId newRole (mKey m) rosterVer) memsToChange' recipients = filter memberCurrent members (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients events let signed = any (either (const False) (isJust . signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch" - (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange) let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ + (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange) pure (errs, changed, acis, signed) where sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c @@ -2874,20 +2888,25 @@ processChatCommand cxt nm = \case withGroupLock "removeMembers" groupId $ do -- TODO [relays] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId - let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members + let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin, anyPrivilegedRemoved) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds memCount = length groupMemberIds when (count /= memCount) $ throwChatError CEGroupMemberNotFound when (memCount > 1 && anyAdmin) $ throwCmdError "can't remove multiple members when admins selected" assertUserGroupRole gInfo $ max GRAdmin maxRole + when (useRelays' gInfo && anyPrivilegedRemoved && memberRole' (membership gInfo) /= GROwner) $ + throwCmdError "only the group owner can remove members, moderators and admins" (errs1, deleted1) <- deleteInvitedMems user invitedMems let recipients = filter memberCurrent members - (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing recipients currentMems + let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && anyPrivilegedRemoved + rosterVer <- if doBumpRoster then Just <$> reserveRosterVersion gInfo else pure Nothing + (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing rosterVer recipients currentMems (errs3, deleted3, acis3, signed3) <- foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], [], False) pendingApprvMems let moderators = filter (\GroupMember {memberRole} -> memberRole >= GRModerator) members (errs4, deleted4, acis4, signed4) <- foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], [], False) pendingRvwMems + forM_ rosterVer $ \v -> broadcastRoster user gInfo v `catchAllErrors` eToView let acis = acis2 <> acis3 <> acis4 errs = errs1 <> errs2 <> errs3 <> errs4 deleted = deleted1 <> deleted2 <> deleted3 <> deleted4 @@ -2902,19 +2921,20 @@ processChatCommand cxt nm = \case unless (null errs) $ toView $ CEvtChatErrors errs pure $ CRUserDeletedMembers user gInfo' deleted withMessages msgSigned -- same order is not guaranteed where - selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) - selectMembers gmIds = foldl' addMember (0, [], [], [], [], GRObserver, False) + selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool) + selectMembers gmIds = foldl' addMember (0, [], [], [], [], GRObserver, False, False) where - addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin) m@GroupMember {groupMemberId, memberStatus, memberRole} + addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin, anyPrivRemoved) m@GroupMember {groupMemberId, memberStatus, memberRole} | groupMemberId `S.member` gmIds = let maxRole' = max maxRole memberRole anyAdmin' = anyAdmin || memberRole >= GRAdmin + anyPrivRemoved' = anyPrivRemoved || isRosterRole memberRole n' = n + 1 in case memberStatus of - GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin') - GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin') - GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin') - _ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin') + GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved') + GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved') + GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved') + _ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin', anyPrivRemoved') | otherwise = acc deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember]) deleteInvitedMems user memsToDelete = do @@ -2927,14 +2947,14 @@ processChatCommand cxt nm = \case deletePendingMember :: ([ChatError], [GroupMember], [AChatItem], Bool) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem], Bool) deletePendingMember (accErrs, accDeleted, accACIs, accSigned) user gInfo recipients m = do (m', scopeInfo) <- mkMemberSupportChatInfo m - (errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m'] + (errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) Nothing recipients [m'] pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs, accSigned || signed) - deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) - deleteMemsSend user gInfo chatScopeInfo recipients memsToDelete = case L.nonEmpty memsToDelete of + deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe VersionRoster -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool) + deleteMemsSend user gInfo chatScopeInfo rosterVer recipients memsToDelete = case L.nonEmpty memsToDelete of Nothing -> pure ([], [], [], False) Just memsToDelete' -> do let chatScope = toChatScope <$> chatScopeInfo - events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete' + events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete' (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients events let signed = any (either (const False) (isJust . signedMsg_)) msgs_ itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) @@ -3134,7 +3154,7 @@ processChatCommand cxt nm = \case (connId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withFastStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode - void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) -- TODO not sure it is correct to set connections status here? pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive @@ -3613,13 +3633,18 @@ processChatCommand cxt nm = \case where cReqHash1 = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} cReqHash2 = contactCReqHash $ CRContactUri crData {crScheme = simplexChat} + -- relay-group joins (only via connectToRelay) carry the target relay member in preparedEntity_; + -- its memberId binds the join signature so a sibling relay can't replay it + relayMemberId_ = case preparedEntity_ of + Just (PCEGroup gInfo m) | useRelays' gInfo -> Just (memberId' m) + _ -> Nothing joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" -- TODO [relays] member: refactor joinContact and up avoiding parallel ifs, xContactId is not used xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ PQSupportOn + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile connect' groupLinkId xContactId_ gInfo_ = do let inGroup = isJust groupLinkId @@ -3634,7 +3659,7 @@ processChatCommand cxt nm = \case subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile_ groupLinkId subMode chatV pqSup - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = @@ -3649,7 +3674,7 @@ processChatCommand cxt nm = \case subMode <- chatReadVar subscriptionMode let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup - void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup + void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing Nothing pqSup ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of @@ -3658,7 +3683,7 @@ processChatCommand cxt nm = \case xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing PQSupportOn + void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing Nothing PQSupportOn ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" @@ -3670,13 +3695,14 @@ processChatCommand cxt nm = \case r <- tryAllErrors $ do (fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- getShortLinkConnReq nm user relayLink relayLinkData_ <- liftIO $ decodeLinkUserData cData - case (relayLinkData_, linkEntityId) of - (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> + relayMemberId <- case (relayLinkData_, linkEntityId) of + (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> do withFastStore $ \db -> updateRelayMemberData db cxt user relayMember (MemberId entityId) (MemberKey relayKey) p + pure $ MemberId entityId _ -> throwChatError $ CEException "relay link: no relay link data or entity id" let cReq = linkConnReq fd relayLinkToConnect = CCLink cReq (Just relayLink) - void $ connectViaContact user (Just $ PCEGroup gInfo relayMember) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing + void $ connectViaContact user (Just $ PCEGroup gInfo (relayMember {memberId = relayMemberId})) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing relayMember' <- withFastStore $ \db -> getGroupMember db cxt user (groupId' gInfo) (groupMemberId' relayMember) pure (relayLink, relayMember', r) syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM () @@ -3712,8 +3738,8 @@ processChatCommand cxt nm = \case pure (connId, chatV) mkXContactId :: Maybe XContactId -> CM XContactId mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure - joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> PQSupport -> CM Connection - joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup = do + joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> Maybe MemberId -> PQSupport -> CM Connection + joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup = do -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile profileToSend <- presentUserBadge user incognitoProfile $ case gInfo_ of @@ -3721,15 +3747,11 @@ processChatCommand cxt nm = \case let allowSimplexLinks = maybe True groupUserAllowSimplexLinks gInfo_' in userProfileInGroup' user allowSimplexLinks incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True - chatEvent <- case gInfo_ of - Just (Just gInfo) | useRelays' gInfo -> do - let GroupInfo {membership = GroupMember {memberId}} = gInfo - memberPubKey <- case groupKeys gInfo of - Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey - Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" - pure $ XMember profileToSend memberId (MemberKey memberPubKey) - _ -> pure $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_ - dm <- encodeConnInfoPQ pqSup chatV chatEvent + dm <- case gInfo_ of + Just (Just gInfo) | useRelays' gInfo -> case relayMemberId_ of + Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend + Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId" + _ -> encodeConnInfoPQ pqSup chatV $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_ subMode <- chatReadVar subscriptionMode void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared ConnJoined @@ -4914,7 +4936,7 @@ runRelayGroupLinkChecks user = do then do -- TODO [relays] emit event to UI when relay own status promoted to RSActive -- CEvtGroupRelayUpdated requires GroupRelay (owner-side), not available on relay side - void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSAccepted RSActive + void $ withStore' $ \db -> updateRelayOwnStatus_ db gInfo RSActive else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive _ -> pure () _ -> pure () diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index c67ad0d514..41f70afda4 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -59,7 +59,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages -import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchMessages, encodeBinaryBatch, encodeFwdElement) +import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchMessages, encodeBatchElement, encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Operators @@ -80,6 +80,7 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Util (encryptFile, shuffle) import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription) import qualified Simplex.FileTransfer.Description as FD +import qualified Simplex.Messaging.Crypto.Lazy as LC import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent @@ -935,9 +936,9 @@ acceptContactRequestAsync liftIO $ setCommandConnId db user cmdId connId getContact db cxt user contactId -acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember acceptGroupJoinRequestAsync - user + user@User {userId} uclId gInfo@GroupInfo {groupProfile, membership, businessChat} cReqInvId @@ -949,12 +950,22 @@ acceptGroupJoinRequestAsync gAccepted gLinkMemRole incognitoProfile - memberKey_ = do + memberKey_ + existingMem_ = do gVar <- asks random let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted + -- a roster-established privileged member attaches a connection to its existing record (keeping + -- owner-authoritative role + key); everyone else is created fresh with the group-link role cxt <- chatStoreCxt - (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ + (groupMemberId, memberId) <- case existingMem_ of + Just m -> do + -- refresh the hash placeholder name from the authenticated join profile; role + key stay roster-authoritative + withStore $ \db -> do + liftIO $ updateGroupMemberStatus db userId m initialStatus + void $ updateMemberProfile db cxt user m cReqProfile + pure (groupMemberId' m, memberId' m) + Nothing -> withStore $ \db -> + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -1169,21 +1180,47 @@ memberIntroEvt gInfo reMember = mRestrictions = memberRestrictions reMember in XGrpMemIntro mInfo mRestrictions +-- Forward the saved owner-signed roster verbatim (reusing its signed shared_msg_id), then the +-- blob chunks, so the recipient verifies the owner signature. +serveRoster :: User -> GroupInfo -> GroupMember -> CM () +serveRoster user gInfo member = + when (member `supportsVersion` groupRosterVersion) $ do + cxt <- chatStoreCxt + withStore' (\db -> getGroupRoster db gInfo) >>= \case + Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}, blob_) -> + case J.eitherDecodeStrict' signedBody :: Either String (ChatMessage 'Json) of + Left e -> logError $ "serveRoster: cannot decode saved roster message: " <> tshow e + Right chatMsg@ChatMessage {msgId} -> + withStore' (\db -> runExceptT $ getGroupMemberById db cxt user ownerGMId) >>= \case + Right owner -> do + let fwd = GrpMsgForward {fwdSender = FwdMember (memberId' owner) (memberShortenedName owner), fwdBrokerTs = brokerTs} + sendFwdMemberMessage member fwd (VMSigned MSSVerified sm chatMsg) + forM_ ((,) <$> msgId <*> blob_) $ \(sid, blob) -> + sendInlineBlobChunks user gInfo [member] sid blob + Left e -> logError $ "serveRoster: roster owner not found: " <> tshow e + Nothing -> pure () + -- Used in groups with relays to introduce moderators and above to a new member, -- and to announce the new member to moderators and above. -- This doesn't create introduction records in db, compared to above methods. introduceInChannel :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM () introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do - modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo + (owners, adminsMods) <- withStore' $ \db -> + (,) <$> getGroupOwners db cxt user gInfo <*> getGroupAdminsMods db cxt user gInfo + let modMs = owners <> adminsMods void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing withStore' $ \db -> setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs] - let introEvts = map (memberIntroEvt gInfo) modMs - forM_ (L.nonEmpty introEvts) $ \introEvts' -> - sendGroupMemberMessages user gInfo conn introEvts' + -- owner intros first so the joiner has the owner profile loaded before applying the saved roster (signed by the owner) + sendIntros owners + serveRoster user gInfo subscriber + sendIntros adminsMods withStore' $ \db -> setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced + where + sendIntros ms = forM_ (L.nonEmpty $ map (memberIntroEvt gInfo) ms) $ \evts -> + sendGroupMemberMessages user gInfo conn evts userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user = userProfileInGroup' user . groupUserAllowSimplexLinks @@ -1215,6 +1252,29 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes | hasObfuscatedSimplexLink s = Nothing | otherwise = maybe (Just s) (\fts -> if any ftIsSimplexLink fts then Nothing else Just s) $ parseMaybeMarkdownList s +-- Roles carried by the roster; owners are on the link, not the roster. +isRosterRole :: GroupMemberRole -> Bool +isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin + +-- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first. +-- Runs on the parsed roster blob. +validateGroupRoster :: [RosterMember] -> [RosterMember] +validateGroupRoster entries = + dedup S.empty $ filter (\RosterMember {role} -> isRosterRole role) entries + where + dedup _ [] = [] + dedup seen (rm@RosterMember {memberId} : rms) + | memberId `S.member` seen = dedup seen rms + | otherwise = rm : dedup (S.insert memberId seen) rms + +-- Privileged members without a known key are skipped (recipients can't verify them). +buildGroupRoster :: [GroupMember] -> [RosterMember] +buildGroupRoster mods = take maxGroupRosterSize $ mapMaybe rosterMember mods + where + rosterMember GroupMember {memberId, memberPubKey, memberRole} + | isRosterRole memberRole = (\k -> RosterMember {memberId, key = MemberKey k, role = memberRole, privileges = 0}) <$> memberPubKey + | otherwise = Nothing + sendHistory :: User -> GroupInfo -> GroupMember -> CM () sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just conn} = @@ -1341,7 +1401,7 @@ setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM G setGroupLinkData nm user gInfo gLink = do cxt <- chatStoreCxt (conn, groupRelays) <- withFastStore $ \db -> - (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo) let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays linkType = if useRelays' gInfo then CCTChannel else CCTGroup sLnk <- shortenShortLink' . setShortLinkType_ linkType =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData)) @@ -1351,7 +1411,7 @@ setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM () setGroupLinkDataAsync user gInfo gLink = do cxt <- chatStoreCxt (conn, groupRelays) <- withStore $ \db -> - (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) + (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo) let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays setAgentConnShortLinkAsync user conn userLinkData (Just crClientData) @@ -1628,13 +1688,16 @@ sendFileInline_ FileTransferMeta {filePath, chunkSize} sharedMsgId sendMsg = chSize = fromIntegral chunkSize parseChatMessage :: Connection -> ByteString -> CM (ChatMessage 'Json) -parseChatMessage conn s = do +parseChatMessage conn s = snd <$> parseChatMessage' conn s +{-# INLINE parseChatMessage #-} + +parseChatMessage' :: Connection -> ByteString -> CM (Maybe SignedMsg, ChatMessage 'Json) +parseChatMessage' conn s = case parseChatMessages s of - [msg] -> liftEither . first (ChatError . errType) $ (\(APMsg _ (ParsedMsg _ _ m)) -> checkEncoding m) =<< msg + [msg] -> liftEither . first (ChatError . errType) $ (\(APMsg _ (ParsedMsg _ sm m)) -> (sm,) <$> checkEncoding m) =<< msg _ -> throwChatError $ CEException "parseChatMessage: single message is expected" where errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) -{-# INLINE parseChatMessage #-} getChatScopeInfo :: StoreCxt -> User -> GroupChatScope -> CM GroupChatScopeInfo getChatScopeInfo cxt user = \case @@ -1831,6 +1894,51 @@ closeFileHandle fileId files = do h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m) liftIO $ mapM_ hClose h_ `catchAll_` pure () +-- The roster file has no chat item, so chat-item file enumeration misses it; clean it up by group. +cleanupGroupRosterFile :: User -> GroupInfo -> CM () +cleanupGroupRosterFile User {userId} GroupInfo {groupId} = do + infos <- withStore' $ \db -> getGroupRosterFileInfo db userId groupId + forM_ infos $ \(fileId, filePath_) -> do + lift $ closeFileHandle fileId rcvFiles + forM_ filePath_ removeFsFile + withStore' $ \db -> do + deleteGroupRosterFile db userId groupId + deleteGroupRosterTransfers db groupId + +-- Supersede/cancel one source relay's in-flight roster transfer: remove its on-disk file + cached +-- handle first (the cascade only does rows), then the files + transfer rows. +cleanupRosterTransfer :: GroupInfo -> GroupMemberId -> CM () +cleanupRosterTransfer gInfo fromMemberId = + withStore' (\db -> getRosterTransferId db gInfo fromMemberId) >>= mapM_ cleanupRosterTransferById + +cleanupRosterTransferById :: Int64 -> CM () +cleanupRosterTransferById transferId = do + file_ <- withStore' $ \db -> getRosterTransferFile db transferId + forM_ file_ $ \(fileId, filePath_) -> do + lift $ closeFileHandle fileId rcvFiles + forM_ filePath_ removeFsFile + withStore' $ \db -> do + deleteRosterTransferFile db transferId + deleteRosterTransfer db transferId + +-- MUST evict the cached AppendMode handle before deleting chunks, else re-driven bytes append +-- after the stale prefix and corrupt the blob. +resetRosterPartialChunks :: RcvFileTransfer -> CM () +resetRosterPartialChunks ft@RcvFileTransfer {fileId, fileStatus} = do + lift $ closeFileHandle fileId rcvFiles + forM_ (rcvFilePath fileStatus) removeFsFile + withStore' $ \db -> deleteRcvFileChunks db ft + where + rcvFilePath = \case + RFSAccepted p -> Just p + RFSConnected p -> Just p + _ -> Nothing + +removeFsFile :: FilePath -> CM () +removeFsFile fp = do + p <- lift $ toFSFilePath fp + removeFile p `catchAllErrors` \_ -> pure () + deleteMembersConnections :: User -> [GroupMember] -> CM () deleteMembersConnections user members = deleteMembersConnections' user members False @@ -2063,6 +2171,26 @@ encodeConnInfoPQ pqSup v chatMsgEvent = do _ -> pure connInfo ECMLarge -> throwChatError $ CEException "large info" +-- conn-info wrapped as a signed element, so the receiver can verify the signature over the body +encodeSignedConnInfo :: MsgEncodingI e => MsgSigning -> ChatMsgEvent e -> CM ByteString +encodeSignedConnInfo signing chatMsgEvent = do + vr <- chatVersionRange + let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent} + case encodeChatMessage maxEncodedInfoLength info of + ECMEncoded body -> pure $ encodeBatchElement (Just $ signChatMsgBody signing body) body + ECMLarge -> throwChatError $ CEException "large signed info" + +-- signed XMember for a relay-group join: proves the joiner holds the member key it asserts, and carries +-- viaRelay = the target relay's memberId inside the signed body so a sibling relay can't accept a replay +encodeXMemberConnInfo :: GroupInfo -> MemberId -> Profile -> CM ByteString +encodeXMemberConnInfo GroupInfo {membership = GroupMember {memberId}, groupKeys} relayMemberId profileToSend = + case groupKeys of + Just GroupKeys {publicGroupId, memberPrivKey} -> + let xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey) (Just relayMemberId) + signing = MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey + in encodeSignedConnInfo signing xMemberEvt + Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" + deliverMessage :: Connection -> CMEventTag e -> MsgBody -> MessageId -> CM (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} @@ -2136,6 +2264,52 @@ sendGroupMessage' user gInfo members chatMsgEvent = ((Right msg) :| [], _) -> pure msg _ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message" +-- TODO [relays] improvement: publish roster_version in link data so the owner can recover the latest version +-- TODO after restoring from a stale backup (relays accept only strictly-greater versions) +-- Persist the next roster version before sending the events that carry it (so a recipient never advances +-- past a version the owner hasn't recorded). The matching blob is broadcast separately, by broadcastRoster, +-- after the change is applied to the owner's members - so the served roster excludes demoted/removed members. +reserveRosterVersion :: GroupInfo -> CM VersionRoster +reserveRosterVersion gInfo = do + let rosterVer = maybe (VersionRoster 0) (\(VersionRoster n) -> VersionRoster (n + 1)) (rosterVersion gInfo) + withStore' $ \db -> setGroupRosterVersion db gInfo rosterVer + pure rosterVer + +broadcastRoster :: User -> GroupInfo -> VersionRoster -> CM () +broadcastRoster user gInfo rosterVer = do + cxt <- chatStoreCxt + (relays, rosterMems) <- withStore' $ \db -> + (,) <$> getGroupRelayMembers db cxt user gInfo <*> getGroupRosterMembers db cxt user gInfo + forM_ (L.nonEmpty relays) $ \relays' -> + sendRoster user gInfo (L.toList relays') rosterVer (buildGroupRoster rosterMems) + +-- Send the current roster (no version bump) to a newly added relay so it can serve joiners. +sendGroupRosterToRelay :: User -> GroupInfo -> GroupMember -> CM () +sendGroupRosterToRelay user gInfo relayMember = + forM_ (rosterVersion gInfo) $ \rosterVer -> do + cxt <- chatStoreCxt + rosterMems <- withStore' $ \db -> getGroupRosterMembers db cxt user gInfo + sendRoster user gInfo [relayMember] rosterVer (buildGroupRoster rosterMems) + +-- Row-less send (no files/snd_files rows, so no send-side cleanup); redelivery is the agent's. +sendRoster :: User -> GroupInfo -> [GroupMember] -> VersionRoster -> [RosterMember] -> CM () +sendRoster user gInfo members rosterVer roster = do + let blob = encodeRosterBlob roster + fileInv = InlineFileInvitation {fileSize = fromIntegral (B.length blob), fileDigest = FD.FileDigest $ LC.sha512Hash $ LB.fromStrict blob} + SndMessage {sharedMsgId} <- sendGroupMessage' user gInfo members (XGrpRoster GroupRoster {version = rosterVer, fileInv}) + sendInlineBlobChunks user gInfo members sharedMsgId blob + +-- Send a binary blob as BFileChunks under a shared_msg_id to the given members (chunked by fileChunkSize). +sendInlineBlobChunks :: User -> GroupInfo -> [GroupMember] -> SharedMsgId -> ByteString -> CM () +sendInlineBlobChunks user gInfo members sharedMsgId blob = do + chSize <- fromIntegral <$> asks (fileChunkSize . config) + go chSize 1 blob + where + go chSize chunkNo bytes = do + let (chunk, rest) = B.splitAt chSize bytes + void $ sendGroupMessage' user gInfo members (BFileChunk sharedMsgId (FileChunk chunkNo chunk)) + unless (B.null rest) $ go chSize (chunkNo + 1) rest + -- Relay advertises its current web preview capability to channel owners. -- Idempotent: sends only when the configured web domain differs from what was last sent, and only to -- owners whose recorded chat version supports relayWebCapVersion (older apps can't parse XGrpRelayCap). @@ -2372,10 +2546,14 @@ saveDirectRcvMSG conn@Connection {connId} agentMsgMeta chatMsg@ChatMessage {chat msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing pure (conn', msg) -saveGroupRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> VerifiedMsg e -> CM (GroupMember, Connection, RcvMessage) +saveGroupRcvMsg :: forall e. MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> VerifiedMsg e -> CM (GroupMember, Connection, RcvMessage) saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta verifiedMsg = do let ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = verifiedChatMsg verifiedMsg - (am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- updateMemberChatVRange authorMember conn chatVRange + -- binary messages (file chunks) carry only the initial-version sentinel, not the sender's range; + -- applying it would downgrade the member's negotiated version and suppress version-gated delivery + (am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- case encoding @e of + SBinary -> pure (authorMember, conn) + SJson -> updateMemberChatVRange authorMember conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta brokerTs = metaBrokerTs agentMsgMeta newMsg = NewRcvMessage {chatMsgEvent, verifiedMsg, brokerTs} @@ -2513,11 +2691,11 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem _ -> Nothing -- TODO [mentions] optimize by avoiding unnecessary parsing -mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d -mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs = +mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d +mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgSigned currentTs = let ts@(_, ft_) = ciContentTexts content hasLink_ = ciContentHasLink content ft_ - in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember Nothing currentTs + in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs = @@ -2679,7 +2857,7 @@ createFeatureEnabledItems_ :: User -> Contact -> CM [AChatItem] createFeatureEnabledItems_ user ct@Contact {mergedPreferences} = forM allChatFeatures $ \(ACF f) -> do let state = featureState $ getContactUserPreference f mergedPreferences - createChatItem user (CDDirectRcv ct) False (uncurry (CIRcvChatFeature $ chatFeature f) state) Nothing Nothing + createChatItem user (CDDirectRcv ct) False (uncurry (CIRcvChatFeature $ chatFeature f) state) Nothing Nothing Nothing createFeatureItems :: MsgDirectionI d => @@ -2709,15 +2887,15 @@ createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do unless (null errs) $ toView' $ CEvtChatErrors errs toView' $ CEvtNewChatItems user acis where - contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) + contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) contactChangedFeatures (Contact {mergedPreferences = cups}, ct'@Contact {mergedPreferences = cups'}) = do let contents = mapMaybe (\(ACF f) -> featureCIContent_ f) allChatFeatures (chatDir ct', False, contents) where - featureCIContent_ :: forall f. FeatureI f => SChatFeature f -> Maybe (CIContent d, Maybe SharedMsgId) + featureCIContent_ :: forall f. FeatureI f => SChatFeature f -> Maybe (CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus) featureCIContent_ f - | state /= state' = Just (fContent ciFeature state', Nothing) - | prefState /= prefState' = Just (fContent ciOffer prefState', Nothing) + | state /= state' = Just (fContent ciFeature state', Nothing, Nothing) + | prefState /= prefState' = Just (fContent ciOffer prefState', Nothing, Nothing) | otherwise = Nothing where fContent :: FeatureContent a d -> (a, Maybe Int) -> CIContent d @@ -2750,16 +2928,16 @@ createGroupFeatureItems_ user cd showGroupAsSender ciContent GroupInfo {fullGrou forM allGroupFeatures $ \(AGF f) -> do let p = getGroupPreference f fullGroupPreferences (_, param, role) = groupFeatureState p - createChatItem user cd showGroupAsSender (ciContent (toGroupFeature f) (toGroupPreference p) param role) Nothing Nothing + createChatItem user cd showGroupAsSender (ciContent (toGroupFeature f) (toGroupPreference p) param role) Nothing Nothing Nothing createInternalChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> CIContent d -> Maybe UTCTime -> CM () createInternalChatItem user cd content itemTs_ = do - ci <- createChatItem user cd False content Nothing itemTs_ + ci <- createChatItem user cd False content Nothing Nothing itemTs_ toView $ CEvtNewChatItems user [ci] -createChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Maybe UTCTime -> CM AChatItem -createChatItem user cd showGroupAsSender content sharedMsgId itemTs_ = - lift (createChatItems user itemTs_ [(cd, showGroupAsSender, [(content, sharedMsgId)])]) >>= \case +createChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Maybe MsgSigStatus -> Maybe UTCTime -> CM AChatItem +createChatItem user cd showGroupAsSender content sharedMsgId msgSigned itemTs_ = + lift (createChatItems user itemTs_ [(cd, showGroupAsSender, [(content, sharedMsgId, msgSigned)])]) >>= \case [Right ci] -> pure ci [Left e] -> throwError e rs -> throwChatError $ CEInternalError $ "createInternalChatItem: expected 1 result, got " <> show (length rs) @@ -2771,7 +2949,7 @@ createChatItems :: (ChatTypeI c, MsgDirectionI d) => User -> Maybe UTCTime -> - [(ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)])] -> + [(ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)])] -> CM' [Either ChatError AChatItem] createChatItems user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime @@ -2780,24 +2958,24 @@ createChatItems user itemTs_ dirsCIContents = do void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents where - updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> IO () + updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> IO () updateChat db cxt createdAt (cd, _, contents) - | any (ciRequiresAttention . fst) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats + | any (\(content, _, _) -> ciRequiresAttention content) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats | otherwise = pure () where memberChatStats :: Maybe (Int, MemberAttention, Int) memberChatStats = case cd of CDGroupRcv _g (Just scope) m -> do - let unread = length $ filter (ciRequiresAttention . fst) contents + let unread = length $ filter (\(content, _, _) -> ciRequiresAttention content) contents in Just (unread, memberAttentionChange unread itemTs_ (Just m) scope, 0) _ -> Nothing - createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> [IO AChatItem] + createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> [IO AChatItem] createACIs db itemTs createdAt (cd, showGroupAsSender, contents) = map createACI contents where - createACI (content, sharedMsgId) = do + createACI (content, sharedMsgId, msgSigned) = do let hasLink_ = ciContentHasLink content Nothing - ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ itemTs createdAt - let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt + ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ msgSigned itemTs createdAt + let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing msgSigned createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci -- rcvMem_ Nothing means message from channel - treated same as message from moderator, diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 2664e217f7..a2ea9870cc 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -24,6 +24,7 @@ import Control.Monad.IO.Unlift import Control.Monad.Reader import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (lefts, partitionEithers, rights) import Data.Foldable (foldr', foldrM) import Data.Functor (($>)) @@ -40,12 +41,14 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime) +import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Data.Word (Word32) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery +import Simplex.Chat.Files (getChatTempDirectory) import Simplex.Chat.Library.Internal import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages @@ -77,7 +80,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Client (getAgentWorker, temporaryOrHostError, waitForUserNetwork, waitForWork, waitWhileSuspended, withWorkItems, withWork_) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Worker (..)) +import Simplex.Messaging.Agent.Env.SQLite (Worker (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay) @@ -87,8 +90,10 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR +import qualified Simplex.Messaging.Crypto.Lazy as LC import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (ErrorType (..), MsgFlags (..), ServiceSub (..), ServiceSubError (..), ServiceSubResult (..)) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) @@ -106,6 +111,13 @@ import UnliftIO.STM smallGroupsRcptsMemLimit :: Int smallGroupsRcptsMemLimit = 20 +-- Verifies member signatures over CBGroup <> (publicGroupId, memberId) <> signedBody under the given key. +-- signatures is NonEmpty so the verification can't be vacuously true. +verifyGroupSig :: C.PublicKeyEd25519 -> B64UrlByteString -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool +verifyGroupSig key publicGroupId memberId signatures signedBody = + let prefix = smpEncode CBGroup <> smpEncode (publicGroupId, memberId) + in all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 key) sig (prefix <> signedBody)) signatures + processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () processAgentMessage _ _ (DEL_RCVQS delQs) = toView $ CEvtAgentRcvQueuesDeleted $ L.map rcvQ delQs @@ -576,7 +588,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo, host) <- withStore $ \db -> do liftIO $ deleteContactCardKeepConn db connId ct createGroupInvitedViaLink db cxt user conn'' glInv - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) @@ -901,8 +913,21 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = GCInviteeMember | isRelay m -> do withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected - gLink <- withStore $ \db -> getGroupLink db user gInfo - setGroupLinkDataAsync user gInfo gLink + if m `supportsVersion` groupRosterVersion + then do + -- send the relay a roster (materializing version 0 for old channels with NULL roster_version); + -- the relay stays RSInvited (unpublishable) until it acks, so no joiner can impersonate a privileged member + gInfo' <- case rosterVersion gInfo of + Just _ -> pure gInfo + Nothing -> do + withStore' $ \db -> setGroupRosterVersion db gInfo (VersionRoster 0) + pure gInfo {rosterVersion = Just (VersionRoster 0)} + sendGroupRosterToRelay user gInfo' m + else do + -- a relay below groupRosterVersion can't ack a roster; publish it on connect as before + -- the handshake (getPublishableGroupRelays and the LINK handler include/activate it by version) + gLink <- withStore $ \db -> getGroupLink db user gInfo + setGroupLinkDataAsync user gInfo gLink | otherwise -> do (gInfo', mStatus) <- if not (memberPending m) @@ -1024,8 +1049,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure newDeliveryTasks processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask) processEvent gInfo' m' verifiedMsg = do - (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg cc <- ask + (m'', conn', msg@RcvMessage {msgId, sharedMsgId_, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg let ctx js = DeliveryTaskContext js False checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) checkSendAsGroup asGroup_ a @@ -1064,23 +1089,25 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd gInfo' m'' memInfo introInv - XGrpMemRole memId memRole -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole msg brokerTs + XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole memberKey rosterVer msg brokerTs XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId - XGrpMemDel memId withMessages -> case encoding @e of - SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages verifiedMsg msg brokerTs False + XGrpMemDel memId withMessages rosterVer -> case encoding @e of + SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages rosterVer verifiedMsg msg brokerTs False SBinary -> pure Nothing XGrpLeave -> fmap ctx <$> xGrpLeave gInfo' m'' msg brokerTs XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg + XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' m'' gr verifiedMsg sharedMsgId_ brokerTs + XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck gInfo' m'' ackVer ackErr -- TODO [knocking] why don't we forward these messages? XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward gInfo' Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs XInfoProbe probe -> Nothing <$ xInfoProbe (COMGroupMember m'') probe XInfoProbeCheck probeHash -> Nothing <$ xInfoProbeCheck (COMGroupMember m'') probeHash XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe - BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta + BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' m'' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) forM deliveryTaskContext_ $ \taskContext -> do let contentChanged :: CM () @@ -1143,7 +1170,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy) - when continued $ sendPendingGroupMessages user gInfo m conn + when continued $ do + when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog + sendPendingGroupMessages user gInfo m conn SWITCH qd phase cStats -> do toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m @@ -1195,9 +1224,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = CFGetRelayDataJoin -> do -- Update relay member with key, memberId and profile from link relayLinkData_ <- liftIO $ decodeLinkUserData cData - case (relayLinkData_, linkEntityId) of - (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> + relayMemberId <- case (relayLinkData_, linkEntityId) of + (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> do withStore $ \db -> updateRelayMemberData db cxt user m (MemberId entityId) (MemberKey relayKey) p + pure $ MemberId entityId _ -> throwChatError $ CEException "relay link: no relay link data or entity id" case cReq of CRContactUri crData@ConnReqUriData {crClientData} -> do @@ -1210,13 +1240,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} -- Update connection with data derived from cReq, now available after getConnShortLinkAsync withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup - let GroupMember {memberId = membershipMemId} = membership - incognitoProfile = incognitoMembershipProfile gInfo - profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - memberPubKey <- case groupKeys gInfo of - Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey - Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" - dm <- encodeConnInfo $ XMember profileToSend membershipMemId (MemberKey memberPubKey) + let incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo + profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile + dm <- encodeXMemberConnInfo gInfo relayMemberId profileToSend subMode <- chatReadVar subscriptionMode void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode CFGetRelayDataAccept -> do @@ -1226,7 +1252,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = relayProfile <- liftIO (decodeLinkUserData cData) >>= \case Just RelayShortLinkData {relayProfile = p} -> pure p Nothing -> throwChatError $ CEException "relay link: no relay link data" - (confId, m', relay) <- withStore $ \db -> do + (confId, m', relay) <- withStore $ \db -> do confId <- getRelayConfId db m liftIO $ updateGroupMemberStatus db userId m GSMemAccepted (m', relay) <- setRelayLinkAccepted db cxt user m (MemberKey relayKey) relayProfile @@ -1239,7 +1265,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = _ -> throwChatError $ CECommandError "unexpected cmdFunction" QCONT -> do continued <- continueSending connEntity conn - when continued $ sendPendingGroupMessages user gInfo m conn + when continued $ do + when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog + sendPendingGroupMessages user gInfo m conn MWARN msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSWarning $ agentSndError err) processConnMWARN connEntity conn err @@ -1312,13 +1340,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = r n'' = Just (ci, CIRcvDecryptionError mde n'') mdeUpdatedCI _ _ = Nothing - receiveFileChunk :: RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () - receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case - FileChunkCancel -> - unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft - ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId - toView $ CEvtRcvFileSndCancelled user ci ft + receiveFileChunk :: Maybe GroupInfo -> RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () + receiveFileChunk gInfo_ ft@RcvFileTransfer {fileId, fileType, chunkSize} conn_ MsgMeta {recipient = (msgId, _), integrity} = \case + FileChunkCancel -> case fileType of + -- cancel only this source's transfer; other relays' in-flight transfers are independent + FTRoster -> do + t_ <- withStore' $ \db -> getRosterTransfer db fileId + forM_ t_ $ \RcvRosterTransfer {rosterTransferId} -> cleanupRosterTransferById rosterTransferId + FTNormal -> + unless (rcvFileCompleteOrCancelled ft) $ do + cancelRcvFileTransfer user ft + ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId + toView $ CEvtRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -1329,30 +1362,33 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = RcvChunkOk -> if B.length chunk /= fromInteger chunkSize then badRcvFileChunk ft "incorrect chunk size" - else withAckMessage' "file msg" agentConnId meta $ appendFileChunk ft chunkNo chunk False + else appendFileChunk ft chunkNo chunk False RcvChunkFinal -> if B.length chunk > fromInteger chunkSize then badRcvFileChunk ft "incorrect chunk size" else do appendFileChunk ft chunkNo chunk True - ci <- withStore $ \db -> do - liftIO $ do - updateRcvFileStatus db fileId FSComplete - updateCIFileStatus db user fileId CIFSRcvComplete - deleteRcvFileChunks db ft - getChatItemByFileId db cxt user fileId - toView $ CEvtRcvFileComplete user ci - mapM_ (deleteAgentConnectionAsync . aConnId) conn_ - RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure () + case fileType of + FTRoster -> forM_ gInfo_ $ \gInfo -> rosterCompletion gInfo ft + FTNormal -> do + ci <- withStore $ \db -> do + liftIO $ do + updateRcvFileStatus db fileId FSComplete + updateCIFileStatus db user fileId CIFSRcvComplete + deleteRcvFileChunks db ft + getChatItemByFileId db cxt user fileId + toView $ CEvtRcvFileComplete user ci + mapM_ (deleteAgentConnectionAsync . aConnId) conn_ + RcvChunkDuplicate -> pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo processContactConnMessage :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM () processContactConnMessage agentMsg connEntity conn UserContact {userContactLinkId = uclId, groupId = ucGroupId_} = case agentMsg of REQ invId pqSupport _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo case chatMsgEvent of XContact p xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ pqSupport - XMember p joiningMemberId joiningMemberKey -> memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey + XMember p joiningMemberId joiningMemberKey viaRelay -> memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey viaRelay XInfo p -> profileContactRequest invId chatVRange p Nothing Nothing Nothing pqSupport XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge @@ -1364,13 +1400,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = CFSetShortLink -> case (ucGroupId_, auData) of (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do - (gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do + (gInfo, gLink, relays, relaysChanged, newlyActiveLinks, newlyActiveGMIds) <- withStore $ \db -> do gInfo <- getGroupInfo db cxt user groupId gLink <- getGroupLink db user gInfo relays <- liftIO $ getGroupRelays db gInfo - (relays', changed, newlyActive) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays + (relays', changed, newlyActiveLinks, newlyActiveGMIds) <- liftIO $ foldrM (updateRelay db) ([], False, [], []) relays liftIO $ setGroupInProgressDone db gInfo - pure (gInfo, gLink, relays', changed, newlyActive) + pure (gInfo, gLink, relays', changed, newlyActiveLinks, newlyActiveGMIds) toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged let GroupSummary {publicMemberCount} = groupSummary gInfo -- Owner is counted in publicMemberCount; > 1 means at least one subscriber. @@ -1388,14 +1424,16 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (null recipients) $ void $ sendGroupMessages user gInfo Nothing False recipients events where - updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact]) -> IO ([GroupRelay], Bool, [ShortLinkContact]) - updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed, newlyActive) = + updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId]) -> IO ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId]) + updateRelay db relay@GroupRelay {groupMemberId, relayLink, relayStatus} (acc, changed, newlyActiveLinks, newlyActiveGMIds) = case relayLink of Just rLink - | rLink `elem` relayLinks && relayStatus == RSAccepted -> do + -- version is gated upstream at publish (getPublishableGroupRelays): an RSAccepted relay + -- whose link is in the published data is necessarily pre-roster, so activate it too + | rLink `elem` relayLinks && (relayStatus == RSAcknowledgedRoster || relayStatus == RSAccepted) -> do relay' <- updateRelayStatus db relay RSActive - pure (relay' : acc, True, rLink : newlyActive) - | rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActive) + pure (relay' : acc, True, rLink : newlyActiveLinks, groupMemberId : newlyActiveGMIds) + | rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds) | relayStatus == RSActive -> do -- Relay link absent from link data — deactivate. -- RSAccepted relays are not deactivated: their own link data update @@ -1404,8 +1442,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- TODO the SMP server, but this owner won't receive a LINK callback for it -- TODO (LINK only fires in response to own setConnShortLink calls). relay' <- updateRelayStatus db relay RSInactive - pure (relay' : acc, True, newlyActive) - _ -> pure (relay : acc, changed, newlyActive) + pure (relay' : acc, True, newlyActiveLinks, newlyActiveGMIds) + _ -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds) _ -> throwChatError $ CECommandError "LINK event expected for a group link only" _ -> throwChatError $ CECommandError "unexpected cmdFunction" MERR _ err -> do @@ -1446,12 +1484,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- they will be updated after connection is accepted. upsertDirectRequestItem cd (requestMsg_, prevSharedMsgId_) Nothing -> do - void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) let e2eContent = CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ Just $ CR.pqSupportToEnc $ reqPQSup - void $ createChatItem user cd False e2eContent Nothing Nothing + void $ createChatItem user cd False e2eContent Nothing Nothing Nothing void $ createFeatureEnabledItems_ user ct forM_ (autoReply addressSettings) $ \mc -> forM_ welcomeSharedMsgId $ \sharedMsgId -> - createChatItem user (CDDirectSnd ct) False (CISndMsgContent mc) (Just sharedMsgId) Nothing + createChatItem user (CDDirectSnd ct) False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing mapM (createRequestItem cd) requestMsg_ case autoAccept of Nothing -> do @@ -1476,13 +1514,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- they will be updated after connection is accepted. upsertBusinessRequestItem cd (requestMsg_, prevSharedMsgId_) Nothing -> do - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) -- TODO [short links] possibly, we can just keep them created where they are created on the business side due to auto-accept -- let e2eContent = CIRcvGroupE2EEInfo $ E2EInfo $ Just False -- no PQ encryption in groups - -- void $ createChatItem user cd False e2eContent Nothing Nothing + -- void $ createChatItem user cd False e2eContent Nothing Nothing Nothing -- void $ createFeatureEnabledItems_ user ct forM_ (autoReply addressSettings) $ \arMC -> forM_ welcomeSharedMsgId $ \sharedMsgId -> - createChatItem user (CDGroupSnd gInfo Nothing) False (CISndMsgContent arMC) (Just sharedMsgId) Nothing + createChatItem user (CDGroupSnd gInfo Nothing) False (CISndMsgContent arMC) (Just sharedMsgId) Nothing Nothing mapM (createRequestItem cd) requestMsg_ toView $ CEvtAcceptingBusinessRequest user gInfo where @@ -1546,7 +1584,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = upsertBusinessRequestItem (CDChannelRcv _ _) = const $ pure Nothing createRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> (SharedMsgId, MsgContent) -> CM AChatItem createRequestItem cd (sharedMsgId, mc) = do - aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing + aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing Nothing toView $ CEvtNewChatItems user [aci] pure aci upsertRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> ((SharedMsgId, MsgContent) -> CM (Maybe AChatItem)) -> (SharedMsgId -> CM ()) -> (Maybe (SharedMsgId, MsgContent), Maybe SharedMsgId) -> CM (Maybe AChatItem) @@ -1574,7 +1612,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = messageError "processContactConnMessage: chat version range incompatible for accepting group join request" | otherwise -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing Nothing (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -1613,19 +1651,37 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = where User {userChatRelay} = user -- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays - -- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember) - memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM () - memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey = do + memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Maybe SignedMsg -> Profile -> MemberId -> MemberKey -> Maybe MemberId -> CM () + memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey@(MemberKey joiningKey) viaRelay = do (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId case gLinkInfo_ of Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing (Just joiningMemberKey) + existing_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberByMemberId db cxt user gInfo joiningMemberId) + case existing_ of + Just rosterMem + -- a privileged memberId's key is owner-authoritative (the roster); the joiner must prove + -- possession of that exact key, otherwise this is an attempt to impersonate it + | isRosterRole (memberRole' rosterMem) -> + if verifyKey gInfo rosterMem + then acceptJoin gInfo (Just rosterMem) (memberRole' rosterMem) + else messageError "memberJoinRequestViaRelay: rejected join claiming privileged memberId (key mismatch or invalid signature)" + _ -> acceptJoin gInfo Nothing gLinkMemRole + Nothing -> + messageError "memberJoinRequestViaRelay: no group link info for relay link" + where + -- replay defense: the viaRelay == own memberId check (viaRelay is in the signed body); without it a sibling relay could replay a privileged member's signed join + verifyKey gInfo rosterMem = case (signedMsg_, groupKeys gInfo) of + (Just SignedMsg {chatBinding = CBGroup, signatures, signedBody}, Just GroupKeys {publicGroupId}) -> + memberPubKey rosterMem == Just joiningKey + && verifyGroupSig joiningKey publicGroupId joiningMemberId signatures signedBody + && viaRelay == Just (memberId' (membership gInfo)) + _ -> False + acceptJoin gInfo existingMem_ acceptRole = do + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' - Nothing -> - messageError "memberJoinRequestViaRelay: no group link info for relay link" muteEventInChannel :: GroupInfo -> GroupMember -> Bool muteEventInChannel gInfo@GroupInfo {membership} m = @@ -2157,7 +2213,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = unless (maybe False memberBlocked m') $ autoAcceptFile file_ processFileInv gInfo' m' = let fileMember_ = if sentAsGroup then Nothing else m' - in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ + in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ FTNormal sharedMsgId_ newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed live = do let mentions' = if maybe False memberBlocked m' then M.empty else mentions (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed live mentions' @@ -2365,7 +2421,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ChatConfig {fileChunkSize} <- asks config fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv inline <- receiveInlineMode fInv' Nothing fileChunkSize - RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) fInv' inline fileChunkSize + RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv' inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" @@ -2461,10 +2517,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = ft <- withStore $ \db -> getDirectFileIdBySharedMsgId db user ct sharedMsgId >>= getRcvFileTransfer db user receiveInlineChunk ft chunk meta - bFileChunkGroup :: GroupInfo -> SharedMsgId -> FileChunk -> MsgMeta -> CM () - bFileChunkGroup GroupInfo {groupId} sharedMsgId chunk meta = do - ft <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId >>= getRcvFileTransfer db user - receiveInlineChunk ft chunk meta + -- A group BFileChunk is a normal inline file chunk or a roster blob chunk, both located by + -- (group_id, shared_msg_id). A chunk matching no in-flight transfer (an orphaned re-served roster + -- chunk, or a missing normal file) is ignored; the outer withAckMessage acks it. + bFileChunkGroup :: GroupInfo -> GroupMember -> SharedMsgId -> FileChunk -> MsgMeta -> CM () + bFileChunkGroup gInfo@GroupInfo {groupId} fromMember sharedMsgId chunk meta = do + fileId_ <- withStore' $ \db -> getGroupRcvFileId db userId groupId (groupMemberId' fromMember) sharedMsgId + forM_ fileId_ $ \fileId -> do + ft <- withStore $ \db -> getRcvFileTransfer db user fileId + case fileType ft of + FTRoster -> receiveRosterChunk gInfo ft meta chunk + FTNormal -> receiveInlineChunk ft chunk meta receiveInlineChunk :: RcvFileTransfer -> FileChunk -> MsgMeta -> CM () receiveInlineChunk RcvFileTransfer {fileId, fileStatus = RFSNew} FileChunk {chunkNo} _ @@ -2474,7 +2537,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = case chunk of FileChunk {chunkNo} -> when (chunkNo == 1) $ startReceivingFile user fileId _ -> pure () - receiveFileChunk ft Nothing meta chunk + receiveFileChunk Nothing ft Nothing meta chunk + + -- A roster re-serve re-sends the blob from chunk 1; discard any partial first, else chunk 1 over a + -- partial is out-of-order (RcvChunkError) and appending after the stale prefix corrupts the blob. + receiveRosterChunk :: GroupInfo -> RcvFileTransfer -> MsgMeta -> FileChunk -> CM () + receiveRosterChunk gInfo ft meta chunk = do + case chunk of + FileChunk {chunkNo} | chunkNo == 1 -> do + last_ <- withStore' $ \db -> getRcvFileLastChunkNo db ft + when (isJust last_) $ resetRosterPartialChunks ft + _ -> pure () + receiveFileChunk (Just gInfo) ft Nothing meta chunk xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext) xFileCancelGroup g@GroupInfo {groupId} m_ sharedMsgId = do @@ -2532,7 +2606,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId - void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let GroupMember {groupMemberId, memberId = membershipMemId} = membership if sameGroupLinkId groupLinkId groupLinkId' then do @@ -2996,40 +3070,63 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = _ -> pure (conn', Nothing) xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ _) msgScope_ msg brokerTs = do - if useRelays' gInfo && isRelay m - then when (memRole > GRMember) $ throwChatError $ CEException "x.grp.mem.new: relay cannot introduce role above member in channel" - else checkHostRole m memRole + xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do + let fromRelay = useRelays' gInfo && isRelay m + unless fromRelay $ checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing - else do + else withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case - Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do - (updatedMember, gInfo') <- withStore $ \db -> do - updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo initialStatus - gInfo' <- - if memberPending updatedMember - then liftIO $ increaseGroupMembersRequireAttention db user gInfo - else pure gInfo - pure (updatedMember, gInfo') - gInfo'' <- updatePublicGroupData user gInfo' - toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember - memberAnnouncedToView updatedMember gInfo'' - pure $ deliveryJobScope updatedMember + Right unknownMember@GroupMember {memberStatus = GSMemUnknown} + -- roster-established privileged member: the relay may update the profile only, + -- never the role or key (those are owner-authoritative via the roster, and + -- XGrpMemNew is unsigned) + | fromRelay && isRosterRole (memberRole' unknownMember) -> do + -- a member's key is immutable per memberId and identical across relays; mismatch + -- is unambiguous relay misbehavior (role can legitimately differ across relays + -- under multi-relay skew, so we deliberately don't warn on role) + let assertedKey = (\(MemberKey k) -> k) <$> assertedKey_ + -- TODO [relays] member: surface relay-key-mismatch as a dedicated event / chat item / relay state + when (assertedKey /= memberPubKey unknownMember) $ + messageWarning $ "x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId=" <> safeDecodeUtf8 (strEncode memId) + updatedMember <- withStore $ \db -> updateRosterMemberAnnounced db cxt user m unknownMember memInfo initialStatus + -- roster members can't be pending, so no members-require-attention update + gInfo' <- updatePublicGroupData user gInfo + toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember + memberAnnouncedToView updatedMember gInfo' + pure $ deliveryJobScope updatedMember + -- asserted privileged but NOT roster-established: relay conjuring a moderator + | fromRelay && isRosterRole memRole -> + messageError "x.grp.mem.new: privileged role not established by roster" $> Nothing + | otherwise -> do + (updatedMember, gInfo') <- withStore $ \db -> do + updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo initialStatus + gInfo' <- + if memberPending updatedMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (updatedMember, gInfo') + gInfo'' <- updatePublicGroupData user gInfo' + toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember + memberAnnouncedToView updatedMember gInfo'' + pure $ deliveryJobScope updatedMember Right _ | useRelays' gInfo -> logInfo "x.grp.mem.new: member already created via another relay" $> Nothing | otherwise -> messageError "x.grp.mem.new error: member already exists" $> Nothing - Left _ -> do - (newMember, gInfo') <- withStore $ \db -> do - newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus - gInfo' <- - if memberPending newMember - then liftIO $ increaseGroupMembersRequireAttention db user gInfo - else pure gInfo - pure (newMember, gInfo') - gInfo'' <- updatePublicGroupData user gInfo' - memberAnnouncedToView newMember gInfo'' - pure $ deliveryJobScope newMember + Left _ + -- a privileged member absent from the roster is a relay conjuring a moderator + | fromRelay && isRosterRole memRole -> messageError "x.grp.mem.new: privileged member not established by roster" $> Nothing + | otherwise -> do + (newMember, gInfo') <- withStore $ \db -> do + newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus + gInfo' <- + if memberPending newMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (newMember, gInfo') + gInfo'' <- updatePublicGroupData user gInfo' + memberAnnouncedToView newMember gInfo'' + pure $ deliveryJobScope newMember where initialStatus = case msgScope_ of Just (MSMember _) -> GSMemPendingReview @@ -3068,10 +3165,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.intro ignored: member already exists" Left _ | useRelays' gInfo -> do - -- owner key must only come from link data, not from relay intro + -- role + key are owner-authoritative (roster); an intro establishes neither - a privileged + -- claim is created at the channel default with no key until the owner-signed roster confirms it + defaultRole <- unknownMemberRole gInfo let memInfo' = case memInfo of MemberInfo mId mRole v p _ - | mRole == GROwner -> MemberInfo mId mRole v p Nothing + | mRole >= GRMember -> MemberInfo mId defaultRole v p Nothing _ -> memInfo void $ withStore $ \db -> createIntroReMember db cxt user gInfo memInfo' memRestrictions | otherwise -> do @@ -3141,28 +3240,241 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = chatV = vr cxt `peerConnChatVersion` mcvr withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode - xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg@RcvMessage {msgSigned} brokerTs + -- rollback defense (channels): apply an owner-signed role/removal only at a version >= the persisted + -- roster_version (not the batch-constant gInfo, which a relay can stale by reordering events in one + -- batch), then advance it in the same transaction; a strictly lower version is a replay and is ignored. + -- Only an owner sender may advance it: a non-owner signed event is rejected by the action that follows, + -- but must not bump roster_version first, or every later owner roster at a lower version is dropped. + applyAtRosterVersion :: GroupInfo -> GroupMember -> Maybe VersionRoster -> CM (Maybe DeliveryJobScope) -> CM (Maybe DeliveryJobScope) + applyAtRosterVersion gInfo sender rosterVer_ action + | not (useRelays' gInfo) = action + | otherwise = case rosterVer_ of + Nothing -> action + Just _ | memberRole' sender /= GROwner -> action + Just v -> do + accept <- withStore' $ \db -> do + cur <- getGroupRosterVersion db gInfo + let fresh = maybe True (v >=) cur + when fresh $ setGroupRosterVersion db gInfo v + pure fresh + if accept + then action + else messageWarning "x.grp.mem: roster version not newer than current, ignoring" $> Nothing + + xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole memberKey_ rosterVer_ msg@RcvMessage {msgSigned} brokerTs | membershipMemId == memId = - let gInfo' = gInfo {membership = membership {memberRole = memRole}} - in changeMemberRole gInfo' membership $ RGEUserRole memRole - | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case - Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole - Left _ -> messageError "x.grp.mem.role with unknown member ID" $> Nothing + applyAtRosterVersion gInfo m rosterVer_ $ + let gInfo' = gInfo {membership = membership {memberRole = memRole}} + in changeMemberRole gInfo' membership False (\db -> updateGroupMemberRole db user membership memRole) $ RGEUserRole memRole + | otherwise = applyAtRosterVersion gInfo m rosterVer_ $ do + defaultRole <- unknownMemberRole gInfo + -- an owner-signed event with a key TOFU-creates an unknown member only for a roster role; else a plain lookup + let allowCreate = useRelays' gInfo && senderRole == GROwner && isRosterRole memRole && isJust memberKey_ + withStore' (\db -> runExceptT $ getCreateUnknownGMByMemberId db cxt user gInfo memId (nameFromMemberId memId) defaultRole allowCreate) >>= \case + Right (Just (member, created)) + -- just created (keyless, and allowCreate ensured the event carries its key): pin key + role + | created, Just (MemberKey pubKey) <- memberKey_ -> + let gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole + in changeMemberRole gInfo member created (\db -> void $ applyMemberKeyRole db member pubKey memRole) gEvent + -- known member: apply the role (its key is established via roster/intro; the event's key is ignored) + | otherwise -> + let gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole + in changeMemberRole gInfo member created (\db -> updateGroupMemberRole db user member memRole) gEvent + -- in relay groups the roster may deliver role update for previously-unknown privileged members + _ | useRelays' gInfo -> pure Nothing + | otherwise -> messageError "x.grp.mem.role with unknown member ID" $> Nothing where GroupMember {memberId = membershipMemId} = membership - changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent + -- applyMember writes the change (role, or role + pinned key for a freshly TOFU-created member); + -- the delivery scope (relay forwarding) is computed on the pre-change role + changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} created applyMember gEvent | senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) = messageError "x.grp.mem.role with insufficient member permissions" $> Nothing + | useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner = + messageError "x.grp.mem.role: only the owner can change member, moderator and admin roles in relay groups" $> Nothing + -- a forwarded role event the roster already applied is a no-op; suppress it. + -- a just-created member is keyless here, so fall through to pin its owner-attested key. + | useRelays' gInfo && not created && fromRole == memRole = pure $ memberEventDeliveryScope member | otherwise = do - withStore' $ \db -> updateGroupMemberRole db user member memRole + withStore' applyMember (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView cInfo ci toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole, msgSigned} pure $ memberEventDeliveryScope member + -- The header only starts the transfer; the roster is applied and the version bumped only at + -- blob completion, so a withheld or corrupted blob leaves the last good roster intact. + -- fromMember is the relay that delivered THIS roster copy (the owner on a relay receiving directly, + -- a relay on a member receiving a forward); author is the owner who signed it. + xGrpRoster :: GroupInfo -> GroupMember -> GroupMember -> GroupRoster -> VerifiedMsg e -> Maybe SharedMsgId -> UTCTime -> CM (Maybe DeliveryJobScope) + xGrpRoster gInfo fromMember author GroupRoster {version = newVer, fileInv = InlineFileInvitation {fileSize, fileDigest}} verifiedMsg sharedMsgId_ brokerTs + -- only an owner may sign a roster; otherwise a relay could route it as a member whose key it controls + | memberRole' author /= GROwner = messageError "x.grp.roster: not signed by an owner" $> Nothing + | fileSize > maxGroupRosterBytes = messageError "x.grp.roster: roster blob size exceeds limit" $> Nothing + | otherwise = case verifiedMsg of + -- unreachable: XGrpRoster is in requiresSignature, so withVerifiedMsg rejected unsigned + VMUnsigned _ -> pure Nothing + VMSigned _ sm _ -> case sharedMsgId_ of + Nothing -> Nothing <$ messageWarning "x.grp.roster: missing shared message id" + Just sharedMsgId -> do + -- per-source pending version (THIS relay's own in-flight transfer), not a single group slot + pendingVer_ <- withStore' $ \db -> getRosterTransferVersion db gInfo (groupMemberId' fromMember) + -- accept a version not below BOTH applied and this source's pending (>=, Nothing below 0): a preceding + -- signed event may have already advanced rosterVersion to this blob's version; a lower one is a downgrade. + if newVer `notBelowRoster` rosterVersion gInfo && newVer `notBelowRoster` pendingVer_ + then startRosterTransfer sm sharedMsgId + else pure Nothing + where + startRosterTransfer sm sharedMsgId = do + -- supersede THIS source's own in-flight transfer (older version or a restart); other relays' transfers are independent + cleanupRosterTransfer gInfo (groupMemberId' fromMember) + let relayHdr = if isUserGrpFwdRelay gInfo then Just sm else Nothing + chSize <- asks $ fileChunkSize . config + let rosterFInv = FileInvitation {fileName = "roster", fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Just IFMSent, fileDescr = Nothing} + -- transfer record + its scratch file in one transaction (file owned by the transfer, keyed per source) + rft@RcvFileTransfer {fileId} <- withStore $ \db -> do + transferId <- liftIO $ createRosterTransfer db gInfo (groupMemberId' fromMember) newVer fileDigest (groupMemberId' author) brokerTs relayHdr + createRosterRcvFile db userId gInfo fromMember transferId sharedMsgId rosterFInv (Just IFMSent) (fromIntegral chSize) + -- accept the chat-item-free file before chunk 1 (FIFO before it) so chunk 1 isn't rejected on RFSNew + -- transient scratch file (consumed into roster_blob, then deleted): temp folder, not the user's files folder / Downloads + tmpDir <- lift getChatTempDirectory + rosterTs <- liftIO getCurrentTime + let GroupInfo {groupId = gId} = gInfo + rosterFile = "roster_" <> show gId <> "_" <> show (groupMemberId' fromMember) <> "_" <> formatTime defaultTimeLocale "%Y%m%d_%H%M%S" rosterTs + filePath <- getRcvFilePath fileId (Just tmpDir) rosterFile False + withStore' $ \db -> startRcvInlineFT db user rft filePath (Just IFMSent) + pure Nothing + + -- Roster version comparison treating Nothing (un-materialized) as below 0. Non-strict (>=) so a relay + -- accepts the owner's blob at the version a preceding signed event already advanced rosterVersion to. + notBelowRoster :: VersionRoster -> Maybe VersionRoster -> Bool + notBelowRoster v = maybe True (v >=) + + -- Blob arrived: verify the owner-attested digest over the plaintext and guard against + -- downgrade before applying; on a relay, ack the owner and re-serve to members. + rosterCompletion :: GroupInfo -> RcvFileTransfer -> CM () + rosterCompletion gInfo RcvFileTransfer {fileId, fileStatus} = + withStore' (\db -> getRosterTransfer db fileId) >>= \case + -- defensive: the file always has its transfer (created together, deleted together) + Nothing -> lift (closeFileHandle fileId rcvFiles) >> forM_ (rosterFilePath fileStatus) removeFsFile + Just RcvRosterTransfer {rosterTransferId = transferId, rosterTransferVersion = pendingVer, rosterTransferDigest = pendingDigest, rosterTransferOwnerGMId = ownerGMId, rosterTransferBrokerTs = rosterBrokerTs, rosterTransferHeader = header_} -> do + owner_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberById db cxt user ownerGMId) + blob <- readAssembledRoster + let isRelay = isUserGrpFwdRelay gInfo + ackErr err = do + cleanupRosterTransferById transferId + when isRelay $ forM_ owner_ $ \owner -> sendRosterAck gInfo owner pendingVer (Just err) + if FD.FileDigest (LC.sha512Hash (LB.fromStrict blob)) /= pendingDigest + then ackErr "relay could not verify the roster blob" + else case parseAll rosterBlobP blob of + Left _ -> ackErr "relay could not parse the roster blob" + Right entries -> case owner_ of + Nothing -> cleanupRosterTransferById transferId + Just author -> do + defaultRole <- unknownMemberRole gInfo + -- gate against the persisted roster_version inside the apply transaction: a roster from another + -- relay (or a preceding signed event) may already have advanced it past this one; a stale + -- completion (e.g. relay1 sent v5 then v6, relay2's v5 completes after v6) is rejected. + results_ <- withStore $ \db -> do + cur <- liftIO $ getGroupRosterVersion db gInfo + if maybe False (pendingVer <) cur + then pure Nothing + else do + res <- processRosterEntries db gInfo defaultRole (validateGroupRoster entries) + liftIO $ setGroupLiveRoster db gInfo pendingVer ownerGMId rosterBrokerTs header_ blob + pure (Just res) + cleanupRosterTransferById transferId + forM_ results_ $ \results -> do + emitRosterResults gInfo author rosterBrokerTs results + -- ack while setting up (own status accepted/acknowledged); a serving (active) relay must not ack broadcasts. + when (isRelay && (relayOwnStatus gInfo == Just RSAccepted || relayOwnStatus gInfo == Just RSAcknowledgedRoster)) $ do + sendRosterAck gInfo author pendingVer Nothing + withStore' $ \db -> void $ updateRelayOwnStatusFromTo db gInfo RSAccepted RSAcknowledgedRoster + where + rosterFilePath = \case + RFSAccepted p -> Just p + RFSConnected p -> Just p + RFSComplete p -> Just p + _ -> Nothing + readAssembledRoster = case rosterFilePath fileStatus of + Just fp -> readAt fp + Nothing -> throwChatError $ CEInternalError "roster file not in progress" + readAt fp = lift (toFSFilePath fp) >>= liftIO . B.readFile + + -- TOFU-apply an owner-signed (key, role) to a resolved member: pin the key if absent; for a keyed + -- member keep the trusted key (Left = reject a different one), else update the role. Right + -- (Just (member-at-new-role, fromRole)) when the role changed, Right Nothing when already current. + applyMemberKeyRole :: DB.Connection -> GroupMember -> C.PublicKeyEd25519 -> GroupMemberRole -> IO (Either MemberId (Maybe (GroupMember, GroupMemberRole))) + applyMemberKeyRole db m pubKey role = case memberPubKey m of + Just k + | k /= pubKey -> pure (Left (memberId' m)) + | memberRole' m == role -> pure (Right Nothing) + | otherwise -> updateGroupMemberRole db user m role $> Right (Just (m {memberRole = role}, memberRole' m)) + Nothing -> setGroupMemberKeyRole db m pubKey role $> Right (Just (m {memberRole = role}, memberRole' m)) + + -- TOFU apply: pin each member's key on first use, then update roles. + processRosterEntries :: DB.Connection -> GroupInfo -> GroupMemberRole -> [RosterMember] -> ExceptT StoreError IO ([MemberId], [(GroupMember, GroupMemberRole, Bool)]) + processRosterEntries db gInfo defaultRole entries = do + let rosterIds = map (\RosterMember {memberId} -> memberId) entries + (cs, as) <- foldrM applyRosterEntry ([], []) entries + currentPriv <- liftIO $ getGroupRosterMembers db cxt user gInfo + reverted <- liftIO $ fmap catMaybes $ forM currentPriv $ \m -> + if memberId' m `notElem` rosterIds + then updateGroupMemberRole db user m defaultRole $> Just ((m :: GroupMember) {memberRole = defaultRole}, memberRole' m, False) + else pure Nothing + pure (cs, as <> reverted) + where + -- entry-level failure (StoreError or IO exception) is muted; the entry is dropped + applyRosterEntry RosterMember {memberId, key = MemberKey pubKey, role} (cs, as) = + ( getCreateUnknownGMByMemberId db cxt user gInfo memberId (nameFromMemberId memberId) defaultRole True >>= \case + Nothing -> pure (cs, as) + Just (m, created) -> liftIO (applyMemberKeyRole db m pubKey role) >>= \case + Left mid -> pure (mid : cs, as) + Right Nothing -> pure (cs, as) + Right (Just (rm, fromR)) -> pure (cs, (rm, fromR, created) : as) + ) + `catchAllErrors` \_ -> pure (cs, as) + + emitRosterResults :: GroupInfo -> GroupMember -> UTCTime -> ([MemberId], [(GroupMember, GroupMemberRole, Bool)]) -> CM () + emitRosterResults gInfo author rosterBrokerTs (conflicts, applied) = do + forM_ conflicts $ \mid' -> + messageWarning $ "x.grp.roster: member key conflict, keeping trusted key, memberId=" <> safeDecodeUtf8 (strEncode mid') + forM_ applied $ \(member, fromRole, created) -> + unless created $ createItems member fromRole + where + createItems member fromRole = do + let toRole = memberRole' member + gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) toRole + (gInfo', author', scopeInfo) <- mkGroupChatScope gInfo author + ci <- createChatItem user (CDGroupRcv gInfo' scopeInfo author') False (CIRcvGroupEvent gEvent) Nothing (Just MSSVerified) (Just rosterBrokerTs) + toView $ CEvtNewChatItems user [ci] + toView CEvtMemberRole {user, groupInfo = gInfo', byMember = author', member, fromRole, toRole, msgSigned = Just MSSVerified} + + sendRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM () + sendRosterAck gInfo owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err) + + xGrpRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM () + xGrpRosterAck gInfo m ackVer err = do + relay_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupRelayByGMId db (groupMemberId' m)) + case relay_ of + Just relay@GroupRelay {relayStatus = RSAccepted} -> case err of + Nothing + | rosterVersion gInfo == Just ackVer -> do + (relay', gLink) <- withStore $ \db -> do + relay' <- liftIO $ updateRelayStatus db relay RSAcknowledgedRoster + gLink <- getGroupLink db user gInfo + pure (relay', gLink) + setGroupLinkDataAsync user gInfo gLink + toView $ CEvtGroupRelayUpdated user gInfo m relay' + | otherwise -> messageWarning "x.grp.roster.ack: stale version, awaiting ack for the current roster" + Just e -> do + relay' <- withStore' $ \db -> updateRelayStatusFromTo db relay RSAccepted RSRejected + toView $ CEvtGroupRelayUpdated user gInfo m relay' + messageError $ "x.grp.roster.ack: relay could not save roster, marked rejected: " <> e + _ -> pure () + checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = when (memberRole < GRAdmin || memberRole < memRole) $ throwChatError (CEGroupContactRole localDisplayName) @@ -3207,11 +3519,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected - xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) - xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do + xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) + xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId - then checkRole membership $ do + then applyAtRosterVersion gInfo m rosterVer_ $ checkRole membership $ do deleteGroupLinkIfExists user gInfo -- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False @@ -3223,7 +3535,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = deleteMemberItem msg gInfo RGEUserDeleted toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned pure $ Just DJSGroup {jobSpec = DJRelayRemoved} - else + else applyAtRosterVersion gInfo m rosterVer_ $ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Left _ -> do messageError "x.grp.mem.del with unknown member ID" @@ -3474,7 +3786,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = processForwardedMsg :: VerifiedMsg 'Json -> Maybe GroupMember -> CM () processForwardedMsg verifiedMsg author_ = do rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author_ verifiedMsg brokerTs - forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of + forM_ rcvMsg_ $ \rcvMsg@RcvMessage {sharedMsgId_, chatMsgEvent = ACME _ event} -> case event of XMsgNew mc -> void $ memberCanSend author_ scope $ newGroupContentMessage gInfo author_ mc rcvMsg msgTs True where @@ -3489,13 +3801,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \author -> void $ xGrpRelayNew gInfo author rl XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs - XGrpMemRole memId memRole -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs + XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole memberKey rosterVer rcvMsg msgTs XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs - XGrpMemDel memId withMessages -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages verifiedMsg rcvMsg msgTs True + XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave gInfo author rcvMsg msgTs XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg + XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo m author gr verifiedMsg sharedMsgId_ msgTs _ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event) where withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM () @@ -3515,12 +3828,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Just sm@SignedMsg {chatBinding, signatures, signedBody} | GroupMember {memberPubKey = Just pubKey, memberId} <- member -> case chatBinding of - CBGroup -> - let prefix = smpEncode chatBinding <> bindingData - bindingData = case groupKeys gInfo of - Just GroupKeys {publicGroupId} -> smpEncode (publicGroupId, memberId) - Nothing -> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups - in signed MSSVerified <$ guard (all (\(MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) + CBGroup + | Just GroupKeys {publicGroupId} <- groupKeys gInfo -> + signed MSSVerified <$ guard (verifyGroupSig pubKey publicGroupId memberId signatures signedBody) + | otherwise -> + let prefix = smpEncode chatBinding <> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups + in signed MSSVerified <$ guard (all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) _ -> signed MSSSignedNoKey <$ guard signatureOptional | otherwise -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) where @@ -3782,10 +4095,15 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do bucketSize <- asks $ deliveryBucketSize . config senders <- withStore' $ \db -> fmap catMaybes . forM senderGMIds $ \sId -> - fmap eitherToMaybe . runExceptT $ do + fmap (join . eitherToMaybe) . runExceptT $ do sender <- getNonRemovedMemberById db cxt user sId - vec <- getMemberRelationsVector db sender - pure (sender, vec) + -- owners are already known to every member (group link + owner-intro in introduceInChannel), + -- so we never disseminate their profile (redundant, and races with joins re-announcing the owner) + if memberRole' sender == GROwner + then pure Nothing + else do + vec <- getMemberRelationsVector db sender + pure $ Just (sender, vec) let missingSenders = length senderGMIds - length senders when (missingSenders > 0) $ logInfo $ "delivery job " <> tshow jobId <> ": " <> tshow missingSenders <> " senders missing; skipping their profile prepend" @@ -3795,13 +4113,8 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do if null senders then pure (body, [], [], []) else do - -- Skip role > GRMember (mirrors xGrpMemNew gate). - -- TODO [relays] public groups: revisit if mods/admins are introduced via this sidecar. - let (encoderErrs, validLabeled) = - partitionEithers - [ (\bs -> (s, bs)) <$> encodeMemberNew (vr cxt) gInfo s - | (s, _) <- senders, memberRole' s <= GRMember - ] + -- all members' profiles disseminate; privileged key/role come from the roster, not here + let (encoderErrs, validLabeled) = partitionEithers [(\bs -> (s, bs)) <$> encodeMemberNew (vr cxt) gInfo s | (s, _) <- senders] (extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxEncodedMsgLength body validLabeled (overflowBatches', large2) = batchProfiles maxEncodedMsgLength overflowLabeled packerErrs = [ChatError (CEInternalError $ "oversized profile element for member " <> show (groupMemberId' s)) | s <- large1 <> large2] diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index f3c3e20e47..223fe492a9 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -48,13 +48,14 @@ import Data.Time.Clock (UTCTime) import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime) import Data.Type.Equality import Data.Typeable (Typeable) -import Data.Word (Word32) +import Data.Word (Word16, Word32) import Simplex.Chat.Badges (LocalBadge) import Simplex.Chat.Call import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared +import qualified Simplex.FileTransfer.Description as FD import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) import Simplex.Messaging.Compression (Compressed, compress1, decompress1, decompressedSize) @@ -84,12 +85,13 @@ import Simplex.Messaging.Version hiding (version) -- 16 - support short link data (2025-06-10) -- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) -- 18 - relay web capabilities (2026-05-31) +-- 19 - group roster (2026-06-18) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 18 +currentChatVersion = VersionChat 19 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -160,6 +162,11 @@ memberSupportVoiceVersion = VersionChat 17 relayWebCapVersion :: VersionChat relayWebCapVersion = VersionChat 18 +-- owner-signed roster (promoted members/moderators/admins) and the relay roster-ack handshake; +-- a relay below this version is published without the handshake (it can't ack a roster) +groupRosterVersion :: VersionChat +groupRosterVersion = VersionChat 19 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -373,6 +380,36 @@ data GrpMsgForward = GrpMsgForward } deriving (Eq, Show) +-- | Owner-signed roster header for the privileged (moderator/admin/member) set; owners +-- are not included, their keys come from the link. The member list itself is not +-- here: it is sent as a binary blob over the inline file transfer, and this header +-- carries only its inline-file invitation (size + owner-attested digest). +data GroupRoster = GroupRoster + { version :: VersionRoster, + fileInv :: InlineFileInvitation + } + deriving (Eq, Show) + +-- | Lean always-inline file invitation for the roster blob, carried in the signed +-- header. The digest authenticates the unsigned blob; integrity is entirely the digest. +data InlineFileInvitation = InlineFileInvitation + { fileSize :: Integer, + fileDigest :: FD.FileDigest + } + deriving (Eq, Show) + +data RosterMember = RosterMember + { memberId :: MemberId, + key :: MemberKey, -- trust-on-first-use pinned per memberId + role :: GroupMemberRole, + privileges :: Word16 -- reserved: serialized as 0, parsed and ignored in v1 + } + deriving (Eq, Show) + +-- RosterMember is binary-only: it rides in the roster blob, never in a JSON message. +instance Encoding RosterMember where + smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges) + smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP instance Encoding FwdSender where smpEncode = \case @@ -439,6 +476,11 @@ data MsgSigning = MsgSigning encodeChatBinding :: ChatBinding -> ByteString -> ByteString encodeChatBinding cb bindingData = smpEncode cb <> bindingData +signChatMsgBody :: MsgSigning -> ByteString -> SignedMsg +signChatMsgBody MsgSigning {bindingTag, bindingData, keyRef, privKey} msgBody = + let sig = C.ASignature C.SEd25519 $ C.sign' privKey (encodeChatBinding bindingTag bindingData <> msgBody) + in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig L.:| [], signedBody = msgBody} + data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json @@ -452,7 +494,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XInfo :: Profile -> ChatMsgEvent 'Json XContact :: {profile :: Profile, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json - XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey} -> ChatMsgEvent 'Json + XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey, viaRelay :: Maybe MemberId} -> ChatMsgEvent 'Json XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json @@ -471,16 +513,18 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json - XGrpMemRole :: MemberId -> GroupMemberRole -> ChatMsgEvent 'Json + XGrpMemRole :: MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> ChatMsgEvent 'Json XGrpMemRestrict :: MemberId -> MemberRestrictions -> ChatMsgEvent 'Json XGrpMemCon :: MemberId -> ChatMsgEvent 'Json XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented - XGrpMemDel :: MemberId -> Bool -> ChatMsgEvent 'Json + XGrpMemDel :: MemberId -> Bool -> Maybe VersionRoster -> ChatMsgEvent 'Json XGrpLeave :: ChatMsgEvent 'Json XGrpDel :: ChatMsgEvent 'Json XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json + XGrpRoster :: GroupRoster -> ChatMsgEvent 'Json + XGrpRosterAck :: VersionRoster -> Maybe Text -> ChatMsgEvent 'Json XGrpMsgForward :: GrpMsgForward -> ChatMessage 'Json -> ChatMsgEvent 'Json XInfoProbe :: Probe -> ChatMsgEvent 'Json XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json @@ -524,6 +568,7 @@ isForwardedGroupMsg ev = case ev of XGrpDel -> True XGrpInfo _ -> True XGrpPrefs _ -> True + XGrpRoster _ -> True _ -> False data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object} @@ -792,6 +837,8 @@ data MsgMention = MsgMention {memberId :: MemberId} newtype MsgMentions = MsgMentions (Map MemberName MsgMention) deriving (Eq, Show) +$(JQ.deriveJSON defaultJSON ''InlineFileInvitation) + $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MCL") ''MsgChatLink) $(JQ.deriveJSON defaultJSON ''LinkOwnerSig) @@ -892,6 +939,28 @@ maxCompressedMsgLength = 13380 maxDecompressedMsgLength :: Int maxDecompressedMsgLength = 65536 +-- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the +-- promotion cap over the promoted (member/moderator/admin) set. +maxGroupRosterSize :: Int +maxGroupRosterSize = 256 + +-- Receive-side byte bound: reject an owner-signed header whose claimed fileSize exceeds what +-- maxGroupRosterSize entries can occupy (128 B/entry is a generous worst case), before a file is created. +-- 128 B/entry ~ memberId + X.509 Ed25519 key (44 B) + role + privileges + 1-byte length prefixes (~2x the ~65 B typical). +maxGroupRosterBytes :: Integer +maxGroupRosterBytes = fromIntegral maxGroupRosterSize * 128 + +-- The byte sequence the owner-signed digest is computed over and verified against +-- before parsing. Word16 count (smpEncodeList's 1-byte count is too small for the future cap). +encodeRosterBlob :: [RosterMember] -> ByteString +encodeRosterBlob ms = smpEncode (fromIntegral (length ms) :: Word16) <> B.concat (map smpEncode ms) + +rosterBlobP :: A.Parser [RosterMember] +rosterBlobP = do + n <- fromIntegral <$> smpP @Word16 + when (n > maxGroupRosterSize) $ fail "roster: too many entries" + A.count n smpP + -- maxEncodedMsgLength - delta between MSG and INFO + 100 (returned for forward overhead) -- delta between MSG and INFO = e2eEncUserMsgLength (no PQ) - e2eEncConnInfoLength (no PQ) = 1008 maxEncodedInfoLength :: Int @@ -1028,6 +1097,8 @@ data CMEventTag (e :: MsgEncoding) where XGrpInfo_ :: CMEventTag 'Json XGrpPrefs_ :: CMEventTag 'Json XGrpDirectInv_ :: CMEventTag 'Json + XGrpRoster_ :: CMEventTag 'Json + XGrpRosterAck_ :: CMEventTag 'Json XGrpMsgForward_ :: CMEventTag 'Json XInfoProbe_ :: CMEventTag 'Json XInfoProbeCheck_ :: CMEventTag 'Json @@ -1088,6 +1159,8 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpInfo_ -> "x.grp.info" XGrpPrefs_ -> "x.grp.prefs" XGrpDirectInv_ -> "x.grp.direct.inv" + XGrpRoster_ -> "x.grp.roster" + XGrpRosterAck_ -> "x.grp.roster.ack" XGrpMsgForward_ -> "x.grp.msg.forward" XInfoProbe_ -> "x.info.probe" XInfoProbeCheck_ -> "x.info.probe.check" @@ -1149,6 +1222,8 @@ instance StrEncoding ACMEventTag where "x.grp.info" -> XGrpInfo_ "x.grp.prefs" -> XGrpPrefs_ "x.grp.direct.inv" -> XGrpDirectInv_ + "x.grp.roster" -> XGrpRoster_ + "x.grp.roster.ack" -> XGrpRosterAck_ "x.grp.msg.forward" -> XGrpMsgForward_ "x.info.probe" -> XInfoProbe_ "x.info.probe.check" -> XInfoProbeCheck_ @@ -1196,7 +1271,7 @@ toCMEventTag msg = case msg of XGrpMemInv _ _ -> XGrpMemInv_ XGrpMemFwd _ _ -> XGrpMemFwd_ XGrpMemInfo _ _ -> XGrpMemInfo_ - XGrpMemRole _ _ -> XGrpMemRole_ + XGrpMemRole {} -> XGrpMemRole_ XGrpMemRestrict _ _ -> XGrpMemRestrict_ XGrpMemCon _ -> XGrpMemCon_ XGrpMemConAll _ -> XGrpMemConAll_ @@ -1206,6 +1281,8 @@ toCMEventTag msg = case msg of XGrpInfo _ -> XGrpInfo_ XGrpPrefs _ -> XGrpPrefs_ XGrpDirectInv {} -> XGrpDirectInv_ + XGrpRoster _ -> XGrpRoster_ + XGrpRosterAck {} -> XGrpRosterAck_ XGrpMsgForward {} -> XGrpMsgForward_ XInfoProbe _ -> XInfoProbe_ XInfoProbeCheck _ -> XInfoProbeCheck_ @@ -1264,6 +1341,7 @@ requiresSignature = \case XGrpMemRestrict_ -> True XGrpLeave_ -> True XGrpRelayNew_ -> True + XGrpRoster_ -> True XInfo_ -> True _ -> False @@ -1332,7 +1410,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do reqContent <- opt "content" let requestMsg = (,) <$> reqMsgId <*> reqContent pure XContact {profile, contactReqId, welcomeMsgId, requestMsg} - XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" + XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay" XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" @@ -1354,16 +1432,18 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" XGrpMemFwd_ -> XGrpMemFwd <$> p "memberInfo" <*> p "memberIntro" XGrpMemInfo_ -> XGrpMemInfo <$> p "memberId" <*> p "profile" - XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role" + XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role" <*> opt "memberKey" <*> opt "rosterVersion" XGrpMemRestrict_ -> XGrpMemRestrict <$> p "memberId" <*> p "memberRestrictions" XGrpMemCon_ -> XGrpMemCon <$> p "memberId" XGrpMemConAll_ -> XGrpMemConAll <$> p "memberId" - XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") + XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") <*> opt "rosterVersion" XGrpLeave_ -> pure XGrpLeave XGrpDel_ -> pure XGrpDel XGrpInfo_ -> XGrpInfo <$> p "groupProfile" XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences" XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope" + XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "fileInv") + XGrpRosterAck_ -> XGrpRosterAck <$> p "version" <*> opt "error" XGrpMsgForward_ -> do fwdSender <- opt "memberId" >>= \case Just memberId -> FwdMember memberId . fromMaybe "" <$> opt "memberName" @@ -1405,7 +1485,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact {profile, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ["profile" .= profile] - XMember {profile, newMemberId, newMemberKey} -> o ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] + XMember {profile, newMemberId, newMemberKey, viaRelay} -> o $ ("viaRelay" .=? viaRelay) ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] @@ -1426,16 +1506,18 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] XGrpMemFwd memInfo memIntro -> o ["memberInfo" .= memInfo, "memberIntro" .= memIntro] XGrpMemInfo memId profile -> o ["memberId" .= memId, "profile" .= profile] - XGrpMemRole memId role -> o ["memberId" .= memId, "role" .= role] + XGrpMemRole memId role memberKey rosterVersion -> o $ ("memberKey" .=? memberKey) $ ("rosterVersion" .=? rosterVersion) ["memberId" .= memId, "role" .= role] XGrpMemRestrict memId memRestrictions -> o ["memberId" .= memId, "memberRestrictions" .= memRestrictions] XGrpMemCon memId -> o ["memberId" .= memId] XGrpMemConAll memId -> o ["memberId" .= memId] - XGrpMemDel memId messages -> o $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId] + XGrpMemDel memId messages rosterVersion -> o $ ("rosterVersion" .=? rosterVersion) $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId] XGrpLeave -> JM.empty XGrpDel -> JM.empty XGrpInfo p -> o ["groupProfile" .= p] XGrpPrefs p -> o ["groupPreferences" .= p] XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq] + XGrpRoster GroupRoster {version, fileInv} -> o ["version" .= version, "fileInv" .= fileInv] + XGrpRosterAck version err -> o $ ("error" .=? err) ["version" .= version] XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} msg -> o $ encodeFwdSender fwdSender ["msg" .= msg, "msgTs" .= fwdBrokerTs] where encodeFwdSender = \case diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index e5ebf8e2bd..2953c1de1f 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -149,7 +149,7 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 6cb8e39bbc..dee72731a8 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -31,12 +31,19 @@ module Simplex.Chat.Store.Files getSharedMsgIdByFileId, getFileIdBySharedMsgId, getGroupFileIdBySharedMsgId, + getGroupRcvFileId, + getGroupRosterFileInfo, + deleteGroupRosterFile, + getRosterTransferFile, + deleteRosterTransferFile, + getRcvFileLastChunkNo, getDirectFileIdBySharedMsgId, getChatRefByFileId, lookupChatRefByFileId, updateSndFileStatus, createRcvFileTransfer, createRcvGroupFileTransfer, + createRosterRcvFile, createRcvStandaloneFileTransfer, appendRcvFD, getRcvFileDescrByRcvFileId, @@ -321,6 +328,64 @@ getGroupFileIdBySharedMsgId db userId groupId sharedMsgId = |] (userId, groupId, sharedMsgId) +-- Resolve the in-flight received group inline file for a chunk: read its file_type by shared_msg_id +-- (LIMIT 1 is safe -- all files sharing a shared_msg_id share a type), then look up by type: a roster +-- file is scoped to its source relay (every relay re-serves the owner's same shared_msg_id, so the source +-- disambiguates), a normal file is by shared_msg_id. Nothing => no in-flight transfer (orphaned chunk). +getGroupRcvFileId :: DB.Connection -> UserId -> Int64 -> GroupMemberId -> SharedMsgId -> IO (Maybe Int64) +getGroupRcvFileId db userId groupId fromMemberId sharedMsgId = do + fileType_ <- getFileType + case fileType_ of + Just FTRoster -> + maybeFirstRow fromOnly $ + DB.query db (rcvFileIdQ <> " AND r.group_member_id = ?") (userId, groupId, sharedMsgId, FTRoster, fromMemberId) + Just FTNormal -> + maybeFirstRow fromOnly $ + DB.query db rcvFileIdQ (userId, groupId, sharedMsgId, FTNormal) + Nothing -> pure Nothing + where + getFileType = + maybeFirstRow fromOnly $ + DB.query db "SELECT file_type FROM files WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? LIMIT 1" (userId, groupId, sharedMsgId) + rcvFileIdQ = + [sql| + SELECT f.file_id FROM files f + JOIN rcv_files r ON r.file_id = f.file_id + WHERE f.user_id = ? AND f.group_id = ? AND f.shared_msg_id = ? AND f.file_type = ? + |] + +-- The roster scratch file for a transfer (for fs/handle cleanup before deleting the transfer). +-- A transfer owns exactly one file (created together in one transaction), so this is single-valued. +getRosterTransferFile :: DB.Connection -> Int64 -> IO (Maybe (Int64, Maybe FilePath)) +getRosterTransferFile db transferId = + maybeFirstRow id $ DB.query db "SELECT file_id, file_path FROM files WHERE roster_transfer_id = ?" (Only transferId) + +-- Deletes a transfer's file row; rcv_files and rcv_file_chunks cascade on the FK. +deleteRosterTransferFile :: DB.Connection -> Int64 -> IO () +deleteRosterTransferFile db transferId = + DB.execute db "DELETE FROM files WHERE roster_transfer_id = ?" (Only transferId) + +-- For roster-file cleanup keyed on the group (not a chat item): every matching file_id and its on-disk +-- path, so the caller evicts the handle and removes the file for each — delete-all like deleteGroupRosterFile. +getGroupRosterFileInfo :: DB.Connection -> UserId -> Int64 -> IO [(Int64, Maybe FilePath)] +getGroupRosterFileInfo db userId groupId = + DB.query + db + "SELECT file_id, file_path FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?" + (userId, groupId, FTRoster) + +-- Deletes the roster files row; rcv_files and rcv_file_chunks cascade on the FK. +deleteGroupRosterFile :: DB.Connection -> UserId -> Int64 -> IO () +deleteGroupRosterFile db userId groupId = + DB.execute db "DELETE FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?" (userId, groupId, FTRoster) + +-- The highest stored chunk number, or Nothing if no partial chunks exist (used to decide +-- whether an arriving chunk 1 is a re-driven transfer that must reset). +getRcvFileLastChunkNo :: DB.Connection -> RcvFileTransfer -> IO (Maybe Integer) +getRcvFileLastChunkNo db RcvFileTransfer {fileId} = + maybeFirstRow fromOnly $ + DB.query db "SELECT chunk_number FROM rcv_file_chunks WHERE file_id = ? ORDER BY chunk_number DESC LIMIT 1" (Only fileId) + getDirectFileIdBySharedMsgId :: DB.Connection -> User -> Contact -> SharedMsgId -> ExceptT StoreError IO Int64 getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId = ExceptT . firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ @@ -379,10 +444,10 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, rfdId, currentTs, currentTs) - pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing} + pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing} -createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer -createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do +createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileType -> Maybe SharedMsgId -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer +createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ fileType sharedMsgId_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_ @@ -394,15 +459,34 @@ createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gNam fileId <- liftIO $ do DB.execute db - "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, currentTs, currentTs) + "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs) insertedRowId db liftIO $ DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, rfdId, currentTs, currentTs) - pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing} + pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing} + +-- Roster scratch file owned by a per-source transfer: group_member_id is the delivering relay (so chunk +-- streams from different relays are distinct files), roster_transfer_id links to the metadata record. +createRosterRcvFile :: DB.Connection -> UserId -> GroupInfo -> GroupMember -> Int64 -> SharedMsgId -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer +createRosterRcvFile db userId GroupInfo {groupId} src@GroupMember {localDisplayName = senderName} transferId sharedMsgId f@FileInvitation {fileName, fileSize, fileConnReq, fileInline} rcvFileInline chunkSize = do + currentTs <- liftIO getCurrentTime + let grpMemberId_ = groupMemberId' src + fileId <- liftIO $ do + DB.execute + db + "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, roster_transfer_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, FPSMP, FTRoster) :. (sharedMsgId, transferId, currentTs, currentTs)) + insertedRowId db + liftIO $ + DB.execute + db + "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, currentTs, currentTs) + pure RcvFileTransfer {fileId, xftpRcvFile = Nothing, fileInvitation = f, fileStatus = RFSNew, fileType = FTRoster, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = Just grpMemberId_, cryptoArgs = Nothing} createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64 createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do @@ -548,7 +632,7 @@ getRcvFileTransfer_ db userId fileId = do SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id @@ -562,9 +646,9 @@ getRcvFileTransfer_ db userId fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. Only (Maybe ContactName) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe ContactName, FileType) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. Only groupName_) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (groupName_, fileType)) = case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> @@ -582,7 +666,7 @@ getRcvFileTransfer_ db userId fileId = do let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} cryptoArgs = CFArgs <$> fileKey <*> fileNonce xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_ - in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs} + in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs} filePath = case filePath_ of Nothing -> throwError $ SERcvFileInvalid fileId Just fp -> pure fp @@ -678,7 +762,15 @@ createRcvFileChunk db RcvFileTransfer {fileId, fileInvitation = FileInvitation { currentTs <- getCurrentTime DB.execute db - "INSERT OR REPLACE INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) VALUES (?,?,?,?,?)" + [sql| + INSERT INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT (file_id, chunk_number) DO UPDATE SET + chunk_agent_msg_id = excluded.chunk_agent_msg_id, + chunk_stored = 0, + created_at = excluded.created_at, + updated_at = excluded.updated_at + |] (fileId, chunkNo, msgId, currentTs, currentTs) pure status where diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 2ea3fa9b84..60531cc1dc 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -67,6 +67,9 @@ module Simplex.Chat.Store.Groups getGroupMembersByIndexes, getSupportScopeMembersByIndexes, getGroupModerators, + getGroupRosterMembers, + getGroupAdminsMods, + getGroupOnlyMembers, getGroupOwners, getGroupRelayMembers, getGroupMembersForExpiration, @@ -84,7 +87,19 @@ module Simplex.Chat.Store.Groups getGroupRelayById, getGroupRelayByGMId, getGroupRelays, - getConnectedGroupRelays, + getPublishableGroupRelays, + setGroupRosterVersion, + getGroupRosterVersion, + getGroupRoster, + RcvRosterTransfer (..), + createRosterTransfer, + getRosterTransferVersion, + getRosterTransferId, + getRosterTransfer, + setGroupLiveRoster, + deleteRosterTransfer, + deleteGroupRosterTransfers, + setGroupMemberKeyRole, createRelayForOwner, getCreateRelayForMember, createRelayConnection, @@ -173,6 +188,7 @@ module Simplex.Chat.Store.Groups createLinkOwnerMember, updatePreparedChannelMember, updateUnknownMemberAnnounced, + updateRosterMemberAnnounced, updateUserMemberProfileSentAt, setGroupCustomData, setGroupUIThemes, @@ -216,6 +232,8 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) +import qualified Simplex.FileTransfer.Description as FD +import Simplex.Messaging.Encoding (smpDecode, smpEncode) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import Simplex.Messaging.Agent.Store.Entity (DBEntityId) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -359,6 +377,7 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) Nothing -> (Nothing, Nothing, Nothing) fullGroupPreferences = mergeGroupPreferences groupPreferences + rosterVersion0 = if useRelays then Just (VersionRoster 0) else Nothing currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do @@ -389,11 +408,11 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays INSERT INTO groups (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, - root_priv_key, root_pub_key, member_priv_key, public_member_count) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + root_priv_key, root_pub_key, member_priv_key, public_member_count, roster_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (BI useRelays, BI useRelays, ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) - :. (rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_) + :. (rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_, rosterVersion0) ) insertedRowId db let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys @@ -420,6 +439,7 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays chatItemTTL = Nothing, uiThemes = Nothing, groupSummary = GroupSummary {currentMembers = 1, publicMemberCount = publicMemberCount_}, + rosterVersion = rosterVersion0, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, @@ -497,6 +517,7 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti chatItemTTL = Nothing, uiThemes = Nothing, groupSummary = GroupSummary {currentMembers = 2, publicMemberCount = Nothing}, + rosterVersion = Nothing, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, @@ -1215,10 +1236,42 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) +-- The full roster set - members, moderators and admins - excluding owners (link-anchored) and +-- left/removed members. For the privileged subset only use getGroupAdminsMods; for plain members +-- only use getGroupOnlyMembers. +getGroupRosterMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupRosterMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + filter memberCurrent . map (toContactMember currentTs cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") + (userId, groupId, userContactId, GRMember, GRModerator, GRAdmin) + +-- Moderators and admins only (excluding owners and plain members) - the set introduced to a +-- joiner; plain members are learned from the roster blob, not via introductions. +getGroupAdminsMods :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupAdminsMods db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + filter memberCurrent . map (toContactMember currentTs cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?)") + (userId, groupId, userContactId, GRModerator, GRAdmin) + +getGroupOnlyMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] +getGroupOnlyMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do + currentTs <- getCurrentTime + filter memberCurrent . map (toContactMember currentTs cxt user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?") + (userId, groupId, userContactId, GRMember) + getGroupOwners :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] getGroupOwners db cxt user@User {userId, userContactId} GroupInfo {groupId} = do - ts <- getCurrentTime - map (toContactMember ts cxt user) + currentTs <- getCurrentTime + filter memberCurrent . map (toContactMember currentTs cxt user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?") @@ -1373,21 +1426,30 @@ getGroupRelays db GroupInfo {groupId} = (groupRelayQuery <> " WHERE gr.group_id = ?") (Only groupId) -getConnectedGroupRelays :: DB.Connection -> GroupInfo -> IO [GroupRelay] -getConnectedGroupRelays db GroupInfo {groupId} = - map toGroupRelay - <$> DB.query - db - ( groupRelayQuery - <> " " - <> [sql| - JOIN group_members m ON m.group_member_id = gr.group_member_id - WHERE gr.group_id = ? - AND m.member_status = ? - AND gr.relay_status IN (?,?) - |] - ) - (groupId, GSMemConnected, RSAccepted, RSActive) +-- Relays whose link is published to subscribers: acked relays (RSAcknowledgedRoster/RSActive) plus +-- pre-roster relays at RSAccepted (below groupRosterVersion, they can't ack a roster), gated by the +-- relay's negotiated version read from its member connection. +getPublishableGroupRelays :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupRelay] +getPublishableGroupRelays db cxt user gInfo@GroupInfo {groupId} = do + relays <- + map toGroupRelay + <$> DB.query + db + ( groupRelayQuery + <> " " + <> [sql| + JOIN group_members m ON m.group_member_id = gr.group_member_id + WHERE gr.group_id = ? + AND m.member_status = ? + AND gr.relay_status IN (?,?,?) + |] + ) + (groupId, GSMemConnected, RSAccepted, RSAcknowledgedRoster, RSActive) + members <- getGroupRelayMembers db cxt user gInfo + pure [gr | gr@GroupRelay {groupMemberId} <- relays, m <- members, groupMemberId' m == groupMemberId, publishable gr m] + where + publishable GroupRelay {relayStatus} m = + relayStatus /= RSAccepted || not (m `supportsVersion` groupRosterVersion) groupRelayQuery :: Query groupRelayQuery = @@ -1405,6 +1467,149 @@ toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, f relayCap = RelayCapabilities {webDomain} in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink, relayCap} +setGroupRosterVersion :: DB.Connection -> GroupInfo -> VersionRoster -> IO () +setGroupRosterVersion db GroupInfo {groupId} v = do + currentTs <- getCurrentTime + DB.execute db "UPDATE groups SET roster_version = ?, updated_at = ? WHERE group_id = ?" (v, currentTs, groupId) + +-- Persisted roster version (the gate baseline; the in-memory gInfo copy is batch-constant and stale on reorder). +getGroupRosterVersion :: DB.Connection -> GroupInfo -> IO (Maybe VersionRoster) +getGroupRosterVersion db GroupInfo {groupId} = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT roster_version FROM groups WHERE group_id = ?" (Only groupId) + +-- The live roster header a relay re-serves to joiners, with the completed blob served alongside it +-- (both are written together at completion, so the blob is present whenever the header is). +getGroupRoster :: DB.Connection -> GroupInfo -> IO (Maybe (GroupMemberId, UTCTime, SignedMsg, Maybe ByteString)) +getGroupRoster db GroupInfo {groupId} = + (>>= toRoster) + <$> maybeFirstRow + id + ( DB.query + db + "SELECT roster_sending_owner_gm_id, roster_broker_ts, roster_msg_chat_binding, roster_msg_signatures, roster_msg_body, roster_blob FROM groups WHERE group_id = ?" + (Only groupId) + ) + where + toRoster (Just ownerGMId, Just brokerTs, Just cb, Just (Binary sigsBs), Just (Binary body), blob_) = + (\sigs -> (ownerGMId, brokerTs, SignedMsg cb sigs body, (\(Binary b) -> b) <$> blob_)) <$> eitherToMaybe (smpDecode sigsBs) + toRoster _ = Nothing + +-- A per-source in-flight roster transfer, keyed (group_id, from_member_id): replaces the single +-- roster_pending_* slot, so two relays serving one member can't share a chunk stream. The signed-header +-- columns are relay-only (NULL on members), promoted to the live roster_msg_* on groups at completion. +createRosterTransfer :: DB.Connection -> GroupInfo -> GroupMemberId -> VersionRoster -> FD.FileDigest -> GroupMemberId -> UTCTime -> Maybe SignedMsg -> IO Int64 +createRosterTransfer db GroupInfo {groupId} fromMemberId v digest ownerGMId brokerTs sm_ = do + -- one in-flight transfer per (group, source): drop any prior row from this source so the INSERT can't hit + -- the UNIQUE constraint even if the caller's fs/handle cleanup was skipped (the scratch file would then leak + -- until group delete, but the transfer never gets stuck). Normally cleanupRosterTransfer ran first. + DB.execute db "DELETE FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?" (groupId, fromMemberId) + DB.execute + db + [sql| + INSERT INTO rcv_roster_transfers + (group_id, from_member_id, roster_version, roster_digest, sending_owner_gm_id, broker_ts, + roster_msg_chat_binding, roster_msg_signatures, roster_msg_body) + VALUES (?,?,?,?,?,?,?,?,?) + |] + ( (groupId, fromMemberId, v, Binary (FD.unFileDigest digest), ownerGMId, brokerTs) + :. ((\SignedMsg {chatBinding} -> chatBinding) <$> sm_, (\SignedMsg {signatures} -> Binary (smpEncode signatures)) <$> sm_, (\SignedMsg {signedBody} -> Binary signedBody) <$> sm_) + ) + insertedRowId db + +getRosterTransferVersion :: DB.Connection -> GroupInfo -> GroupMemberId -> IO (Maybe VersionRoster) +getRosterTransferVersion db GroupInfo {groupId} fromMemberId = + maybeFirstRow fromOnly $ + DB.query db "SELECT roster_version FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?" (groupId, fromMemberId) + +getRosterTransferId :: DB.Connection -> GroupInfo -> GroupMemberId -> IO (Maybe Int64) +getRosterTransferId db GroupInfo {groupId} fromMemberId = + maybeFirstRow fromOnly $ + DB.query db "SELECT roster_transfer_id FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?" (groupId, fromMemberId) + +-- An in-flight received roster transfer (a rcv_roster_transfers row joined to its scratch file), read at +-- completion. The header is the relay's re-serve SignedMsg -- present only on a serving relay (NULL on a +-- member, whose live roster_msg_* stay NULL so it never re-serves). +data RcvRosterTransfer = RcvRosterTransfer + { rosterTransferId :: Int64, + rosterTransferVersion :: VersionRoster, + rosterTransferDigest :: FD.FileDigest, + rosterTransferOwnerGMId :: GroupMemberId, + rosterTransferBrokerTs :: UTCTime, + rosterTransferHeader :: Maybe SignedMsg + } + deriving (Show) + +-- The in-flight transfer for a received roster file (joined via files.roster_transfer_id), with its +-- relay-only signed header. Read at completion to apply, promote into the live roster, and ack. +getRosterTransfer :: DB.Connection -> Int64 -> IO (Maybe RcvRosterTransfer) +getRosterTransfer db fileId = + (>>= toTransfer) + <$> maybeFirstRow + id + ( DB.query + db + [sql| + SELECT t.roster_transfer_id, t.roster_version, t.roster_digest, t.sending_owner_gm_id, t.broker_ts, + t.roster_msg_chat_binding, t.roster_msg_signatures, t.roster_msg_body + FROM rcv_roster_transfers t + JOIN files f ON f.roster_transfer_id = t.roster_transfer_id + WHERE f.file_id = ? + |] + (Only fileId) + ) + where + toTransfer (tId, v, Binary d, ownerGMId, brokerTs, cb_, sigs_, body_) = + Just + RcvRosterTransfer + { rosterTransferId = tId, + rosterTransferVersion = v, + rosterTransferDigest = FD.FileDigest d, + rosterTransferOwnerGMId = ownerGMId, + rosterTransferBrokerTs = brokerTs, + rosterTransferHeader = sm_ + } + where + sm_ = case (cb_, sigs_, body_) of + (Just cb, Just (Binary sigsBs), Just (Binary body)) -> + (\sigs -> SignedMsg cb sigs body) <$> eitherToMaybe (smpDecode sigsBs) + _ -> Nothing + +-- Write the single live roster on groups from a completed transfer's values (header NULL on a member, +-- so its live roster_msg_* stay NULL and it never re-serves; only relays re-serve). +setGroupLiveRoster :: DB.Connection -> GroupInfo -> VersionRoster -> GroupMemberId -> UTCTime -> Maybe SignedMsg -> ByteString -> IO () +setGroupLiveRoster db GroupInfo {groupId} v ownerGMId brokerTs sm_ blob = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE groups SET + roster_version = ?, roster_blob = ?, + roster_sending_owner_gm_id = ?, roster_broker_ts = ?, + roster_msg_chat_binding = ?, roster_msg_signatures = ?, roster_msg_body = ?, + updated_at = ? + WHERE group_id = ? + |] + ( (v, Binary blob, ownerGMId, brokerTs) + :. ((\SignedMsg {chatBinding} -> chatBinding) <$> sm_, (\SignedMsg {signatures} -> Binary (smpEncode signatures)) <$> sm_, (\SignedMsg {signedBody} -> Binary signedBody) <$> sm_, currentTs, groupId) + ) + +-- Delete one in-flight transfer row (its files/rcv_files/rcv_file_chunks are removed separately, with +-- the on-disk file). Caller removes the fs file + cached handle first. +deleteRosterTransfer :: DB.Connection -> Int64 -> IO () +deleteRosterTransfer db transferId = + DB.execute db "DELETE FROM rcv_roster_transfers WHERE roster_transfer_id = ?" (Only transferId) + +-- All in-flight transfers for a group (group delete). +deleteGroupRosterTransfers :: DB.Connection -> Int64 -> IO () +deleteGroupRosterTransfers db groupId = + DB.execute db "DELETE FROM rcv_roster_transfers WHERE group_id = ?" (Only groupId) + +setGroupMemberKeyRole :: DB.Connection -> GroupMember -> C.PublicKeyEd25519 -> GroupMemberRole -> IO () +setGroupMemberKeyRole db GroupMember {groupMemberId} pubKey role = do + currentTs <- getCurrentTime + DB.execute db "UPDATE group_members SET member_pub_key = ?, member_role = ?, updated_at = ? WHERE group_member_id = ?" (pubKey, role, currentTs, groupMemberId) + createRelayForOwner :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do currentTs <- liftIO getCurrentTime @@ -1713,9 +1918,9 @@ getRelayServedGroups db cxt User {userId, userContactId} = do <$> DB.query db ( groupInfoQuery - <> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?)" + <> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?, ?)" ) - (userId, userContactId, RSAccepted, RSActive) + (userId, userContactId, RSAccepted, RSAcknowledgedRoster, RSActive) getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)] getRelayPublishableGroups db User {userId, userContactId} = @@ -3182,11 +3387,11 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI where VersionRange minV maxV = vr cxt --- member_pub_key is not updated here — introduced members are owners --- whose keys are loaded from link data (trusted out-of-band). --- Updating from an in-band message would allow a compromised relay to substitute keys. +-- Intro refreshes only profile / status / peer version. Role and key stay owner-authoritative +-- (the owner-signed roster for members/moderators/admins, link data for owners), so taking either from +-- an in-band relayed intro would let a compromised relay substitute them. updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember -updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do +updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} = do _ <- updateMemberProfile db cxt user member profile currentTs <- liftIO getCurrentTime liftIO $ @@ -3194,14 +3399,13 @@ updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupM db [sql| UPDATE group_members - SET member_role = ?, - member_status = ?, + SET member_status = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ? |] - (memberRole, GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId) + (GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId) getGroupMemberById db cxt user groupMemberId where VersionRange minV maxV = maybe memberChatVRange fromChatVRange v @@ -3233,6 +3437,30 @@ updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMem VersionRange minV maxV = maybe memberChatVRange fromChatVRange v memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey +-- Like updateUnknownMemberAnnounced but preserves member_role and member_pub_key +-- (roster-established for moderators/admins; the dissemination carries only the profile). +updateRosterMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +updateRosterMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} status = do + _ <- updateMemberProfile db cxt user unknownMember profile + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + UPDATE group_members + SET member_category = ?, + member_status = ?, + invited_by_group_member_id = ?, + peer_chat_min_version = ?, + peer_chat_max_version = ?, + updated_at = ? + WHERE user_id = ? AND group_member_id = ? + |] + ((GCPostMember, status, groupMemberId' invitingMember) :. (minV, maxV, currentTs, userId, groupMemberId)) + getGroupMemberById db cxt user groupMemberId + where + VersionRange minV maxV = maybe memberChatVRange fromChatVRange v + updateUserMemberProfileSentAt :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO () updateUserMemberProfileSentAt db User {userId} GroupInfo {groupId} sentTs = DB.execute diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index cf12db7ec1..644d73137d 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -238,10 +238,7 @@ createNewSndMessage db gVar connOrGroupId chatMsgEvent msgSigning_ encodeMessage case encodeMessage (SharedMsgId sharedMsgId) of ECMLarge -> pure $ Left SELargeMsg ECMEncoded msgBody -> do - let signedMsg_ = signBody <$> msgSigning_ - signBody MsgSigning {bindingTag, bindingData, keyRef, privKey} = - let sig = C.ASignature C.SEd25519 $ C.sign' privKey (encodeChatBinding bindingTag bindingData <> msgBody) - in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig :| [], signedBody = msgBody} + let signedMsg_ = (`signChatMsgBody` msgBody) <$> msgSigning_ createdAt <- getCurrentTime DB.execute db @@ -584,9 +581,9 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgS CDChannelRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> (Just $ Just userMemberId == memberId, memberId) -createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> UTCTime -> UTCTime -> IO ChatItemId -createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink itemTs = - createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing Nothing +createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> Maybe MsgSigStatus -> UTCTime -> UTCTime -> IO ChatItemId +createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink msgSigned itemTs = + createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing msgSigned where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 20acf0b602..4b814d0434 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -37,6 +37,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at import Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain +import Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -73,7 +74,8 @@ schemaMigrations = ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), - ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain) + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain), + ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs new file mode 100644 index 0000000000..892b2c70da --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs @@ -0,0 +1,64 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260602_group_roster :: Text +m20260602_group_roster = + [r| +ALTER TABLE groups ADD COLUMN roster_version BIGINT; +ALTER TABLE groups ADD COLUMN roster_msg_body BYTEA; +ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT; +ALTER TABLE groups ADD COLUMN roster_msg_signatures BYTEA; +ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id BIGINT; +ALTER TABLE groups ADD COLUMN roster_broker_ts TIMESTAMPTZ; +ALTER TABLE groups ADD COLUMN roster_blob BYTEA; + +CREATE TABLE rcv_roster_transfers( + roster_transfer_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + from_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + roster_version BIGINT NOT NULL, + roster_digest BYTEA NOT NULL, + sending_owner_gm_id BIGINT NOT NULL, + broker_ts TIMESTAMPTZ NOT NULL, + roster_msg_body BYTEA, + roster_msg_chat_binding TEXT, + roster_msg_signatures BYTEA, + created_at TEXT NOT NULL DEFAULT (now()), + updated_at TEXT NOT NULL DEFAULT (now()) +); +CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON rcv_roster_transfers(group_id, from_member_id); +CREATE INDEX idx_rcv_roster_transfers_from_member_id ON rcv_roster_transfers(from_member_id); + +ALTER TABLE files ADD COLUMN shared_msg_id BYTEA; +ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal'; +ALTER TABLE files ADD COLUMN roster_transfer_id BIGINT; +CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id); +CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id); +|] + +down_m20260602_group_roster :: Text +down_m20260602_group_roster = + [r| +DROP INDEX idx_files_roster_transfer_id; +DROP INDEX idx_files_group_id_shared_msg_id; +ALTER TABLE files DROP COLUMN roster_transfer_id; +ALTER TABLE files DROP COLUMN file_type; +ALTER TABLE files DROP COLUMN shared_msg_id; + +DROP INDEX idx_rcv_roster_transfers_from_member_id; +DROP INDEX idx_rcv_roster_transfers_group_id_from_member_id; +DROP TABLE rcv_roster_transfers; + +ALTER TABLE groups DROP COLUMN roster_blob; +ALTER TABLE groups DROP COLUMN roster_broker_ts; +ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id; +ALTER TABLE groups DROP COLUMN roster_msg_signatures; +ALTER TABLE groups DROP COLUMN roster_msg_chat_binding; +ALTER TABLE groups DROP COLUMN roster_msg_body; +ALTER TABLE groups DROP COLUMN roster_version; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 89cddd48e5..861224ff56 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -752,7 +752,10 @@ CREATE TABLE test_chat_schema.files ( file_crypto_key bytea, file_crypto_nonce bytea, note_folder_id bigint, - redirect_file_id bigint + redirect_file_id bigint, + shared_msg_id bytea, + file_type text DEFAULT 'normal'::text NOT NULL, + roster_transfer_id bigint ); @@ -977,9 +980,16 @@ CREATE TABLE test_chat_schema.groups ( public_member_count bigint, relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, - relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, + relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL, relay_inactive_at timestamp with time zone, - relay_sent_web_domain text + relay_sent_web_domain text, + roster_version bigint, + roster_msg_body bytea, + roster_msg_chat_binding text, + roster_msg_signatures bytea, + roster_sending_owner_gm_id bigint, + roster_broker_ts timestamp with time zone, + roster_blob bytea ); @@ -1206,6 +1216,34 @@ CREATE TABLE test_chat_schema.rcv_files ( +CREATE TABLE test_chat_schema.rcv_roster_transfers ( + roster_transfer_id bigint NOT NULL, + group_id bigint NOT NULL, + from_member_id bigint NOT NULL, + roster_version bigint NOT NULL, + roster_digest bytea NOT NULL, + sending_owner_gm_id bigint NOT NULL, + broker_ts timestamp with time zone NOT NULL, + roster_msg_body bytea, + roster_msg_chat_binding text, + roster_msg_signatures bytea, + created_at text DEFAULT now() NOT NULL, + updated_at text DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.rcv_roster_transfers ALTER COLUMN roster_transfer_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.rcv_roster_transfers_roster_transfer_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.received_probes ( received_probe_id bigint NOT NULL, contact_id bigint, @@ -1739,6 +1777,11 @@ ALTER TABLE ONLY test_chat_schema.rcv_files +ALTER TABLE ONLY test_chat_schema.rcv_roster_transfers + ADD CONSTRAINT rcv_roster_transfers_pkey PRIMARY KEY (roster_transfer_id); + + + ALTER TABLE ONLY test_chat_schema.received_probes ADD CONSTRAINT received_probes_pkey PRIMARY KEY (received_probe_id); @@ -2272,10 +2315,18 @@ CREATE INDEX idx_files_group_id ON test_chat_schema.files USING btree (group_id) +CREATE INDEX idx_files_group_id_shared_msg_id ON test_chat_schema.files USING btree (group_id, shared_msg_id); + + + CREATE INDEX idx_files_redirect_file_id ON test_chat_schema.files USING btree (redirect_file_id); +CREATE INDEX idx_files_roster_transfer_id ON test_chat_schema.files USING btree (roster_transfer_id); + + + CREATE INDEX idx_files_user_id ON test_chat_schema.files USING btree (user_id); @@ -2448,6 +2499,14 @@ CREATE INDEX idx_rcv_files_group_member_id ON test_chat_schema.rcv_files USING b +CREATE INDEX idx_rcv_roster_transfers_from_member_id ON test_chat_schema.rcv_roster_transfers USING btree (from_member_id); + + + +CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON test_chat_schema.rcv_roster_transfers USING btree (group_id, from_member_id); + + + CREATE INDEX idx_received_probes_contact_id ON test_chat_schema.received_probes USING btree (contact_id); @@ -3133,6 +3192,16 @@ ALTER TABLE ONLY test_chat_schema.rcv_files +ALTER TABLE ONLY test_chat_schema.rcv_roster_transfers + ADD CONSTRAINT rcv_roster_transfers_from_member_id_fkey FOREIGN KEY (from_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.rcv_roster_transfers + ADD CONSTRAINT rcv_roster_transfers_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.received_probes ADD CONSTRAINT received_probes_contact_id_fkey FOREIGN KEY (contact_id) REFERENCES test_chat_schema.contacts(contact_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 78838c507f..c9dd316aee 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -160,6 +160,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at import Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain +import Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -319,7 +320,8 @@ schemaMigrations = ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), - ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain) + ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain), + ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs new file mode 100644 index 0000000000..d68fea3a56 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs @@ -0,0 +1,63 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260602_group_roster :: Query +m20260602_group_roster = + [sql| +ALTER TABLE groups ADD COLUMN roster_version INTEGER; +ALTER TABLE groups ADD COLUMN roster_msg_body BLOB; +ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT; +ALTER TABLE groups ADD COLUMN roster_msg_signatures BLOB; +ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id INTEGER; +ALTER TABLE groups ADD COLUMN roster_broker_ts TEXT; +ALTER TABLE groups ADD COLUMN roster_blob BLOB; + +CREATE TABLE rcv_roster_transfers( + roster_transfer_id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + from_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + roster_version INTEGER NOT NULL, + roster_digest BLOB NOT NULL, + sending_owner_gm_id INTEGER NOT NULL, + broker_ts TEXT NOT NULL, + roster_msg_body BLOB, + roster_msg_chat_binding TEXT, + roster_msg_signatures BLOB, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; +CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON rcv_roster_transfers(group_id, from_member_id); +CREATE INDEX idx_rcv_roster_transfers_from_member_id ON rcv_roster_transfers(from_member_id); + +ALTER TABLE files ADD COLUMN shared_msg_id BLOB; +ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal'; +ALTER TABLE files ADD COLUMN roster_transfer_id INTEGER; +CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id); +CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id); +|] + +down_m20260602_group_roster :: Query +down_m20260602_group_roster = + [sql| +DROP INDEX idx_files_roster_transfer_id; +DROP INDEX idx_files_group_id_shared_msg_id; +ALTER TABLE files DROP COLUMN roster_transfer_id; +ALTER TABLE files DROP COLUMN file_type; +ALTER TABLE files DROP COLUMN shared_msg_id; + +DROP INDEX idx_rcv_roster_transfers_from_member_id; +DROP INDEX idx_rcv_roster_transfers_group_id_from_member_id; +DROP TABLE rcv_roster_transfers; + +ALTER TABLE groups DROP COLUMN roster_blob; +ALTER TABLE groups DROP COLUMN roster_broker_ts; +ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id; +ALTER TABLE groups DROP COLUMN roster_msg_signatures; +ALTER TABLE groups DROP COLUMN roster_msg_chat_binding; +ALTER TABLE groups DROP COLUMN roster_msg_body; +ALTER TABLE groups DROP COLUMN roster_version; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 0cac477bd5..fb6166f8c9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -30,6 +30,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -82,6 +83,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -148,7 +150,7 @@ Query: g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, @@ -286,6 +288,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -320,6 +323,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -354,6 +358,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -537,6 +542,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -570,6 +576,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -604,6 +611,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -638,6 +646,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1082,6 +1091,17 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?) +Query: + SELECT t.roster_transfer_id, t.roster_version, t.roster_digest, t.sending_owner_gm_id, t.broker_ts, + t.roster_msg_chat_binding, t.roster_msg_signatures, t.roster_msg_body + FROM rcv_roster_transfers t + JOIN files f ON f.roster_transfer_id = t.roster_transfer_id + WHERE f.file_id = ? + +Plan: +SEARCH f USING INTEGER PRIMARY KEY (rowid=?) +SEARCH t USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE chat_items SET user_id = ?, updated_at = ? @@ -1182,6 +1202,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1217,6 +1238,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1266,8 +1288,8 @@ Query: INSERT INTO groups (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, - root_priv_key, root_pub_key, member_priv_key, public_member_count) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + root_priv_key, root_pub_key, member_priv_key, public_member_count, roster_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1669,7 +1691,7 @@ Query: SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id @@ -1865,6 +1887,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1899,6 +1922,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1946,6 +1970,17 @@ Query: Plan: +Query: + INSERT INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT (file_id, chunk_number) DO UPDATE SET + chunk_agent_msg_id = excluded.chunk_agent_msg_id, + chunk_stored = 0, + created_at = excluded.created_at, + updated_at = excluded.updated_at + +Plan: + Query: INSERT INTO remote_hosts (host_device_name, store_path, bind_addr, bind_iface, bind_port, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub) @@ -3708,6 +3743,15 @@ Plan: SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=?) SEARCH f USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) +Query: + SELECT f.file_id FROM files f + JOIN rcv_files r ON r.file_id = f.file_id + WHERE f.user_id = ? AND f.group_id = ? AND f.shared_msg_id = ? AND f.file_type = ? + AND r.group_member_id = ? +Plan: +SEARCH f USING INDEX idx_files_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) +SEARCH r USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=? AND rowid=?) + Query: SELECT file_id, contact_id, group_id, note_folder_id FROM files @@ -4059,6 +4103,19 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_category = ?, + member_status = ?, + invited_by_group_member_id = ?, + peer_chat_min_version = ?, + peer_chat_max_version = ?, + updated_at = ? + WHERE user_id = ? AND group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_id = ?, member_pub_key = ?, updated_at = ? @@ -4077,8 +4134,7 @@ SCAN group_members Query: UPDATE group_members - SET member_role = ?, - member_status = ?, + SET member_status = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? @@ -4783,6 +4839,14 @@ Query: Plan: +Query: + INSERT INTO rcv_roster_transfers + (group_id, from_member_id, roster_version, roster_digest, sending_owner_gm_id, broker_ts, + roster_msg_chat_binding, roster_msg_signatures, roster_msg_body) + VALUES (?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO remote_controllers (ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key) @@ -5259,6 +5323,17 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups SET + roster_version = ?, roster_blob = ?, + roster_sending_owner_gm_id = ?, roster_broker_ts = ?, + roster_msg_chat_binding = ?, roster_msg_signatures = ?, roster_msg_body = ?, + updated_at = ? + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE msg_deliveries SET delivery_status = ?, updated_at = ? @@ -5276,6 +5351,14 @@ Query: Plan: SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE rcv_file_chunks + SET chunk_stored = 1, updated_at = ? + WHERE file_id = ? AND chunk_number = ? + +Plan: +SEARCH rcv_file_chunks USING PRIMARY KEY (file_id=? AND chunk_number=?) + Query: UPDATE rcv_files SET to_receive = 1, user_approved_relays = ?, updated_at = ? @@ -5391,7 +5474,7 @@ Query: g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, @@ -5429,7 +5512,7 @@ Query: g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, @@ -5460,7 +5543,7 @@ Query: g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, @@ -5742,6 +5825,26 @@ SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?) +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -5872,11 +5975,11 @@ Query: FROM group_relays gr JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id - JOIN group_members m ON m.group_member_id = gr.group_member_id - WHERE gr.group_id = ? - AND m.member_status = ? - AND gr.relay_status IN (?,?) - + JOIN group_members m ON m.group_member_id = gr.group_member_id + WHERE gr.group_id = ? + AND m.member_status = ? + AND gr.relay_status IN (?,?,?) + Plan: SEARCH gr USING INDEX idx_group_relays_group_id (group_id=?) SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) @@ -6491,6 +6594,14 @@ SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND loca SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?) SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +Query: DELETE FROM files WHERE roster_transfer_id = ? +Plan: +SEARCH files USING COVERING INDEX idx_files_roster_transfer_id (roster_transfer_id=?) +SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) +SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) + Query: DELETE FROM files WHERE user_id = ? AND contact_id = ? Plan: SEARCH files USING INDEX idx_files_contact_id (contact_id=?) @@ -6499,9 +6610,18 @@ SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) +Query: DELETE FROM files WHERE user_id = ? AND group_id = ? AND file_type = ? +Plan: +SEARCH files USING INDEX idx_files_group_id (group_id=?) +SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?) +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) +SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) + Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -6531,6 +6651,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -6560,6 +6681,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM groups WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_id (group_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_group_id (group_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_group_id (group_id=?) @@ -6632,6 +6754,18 @@ Query: DELETE FROM rcv_file_chunks WHERE file_id = ? Plan: SEARCH rcv_file_chunks USING COVERING INDEX idx_rcv_file_chunks_file_id (file_id=?) +Query: DELETE FROM rcv_roster_transfers WHERE group_id = ? +Plan: +SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=?) + +Query: DELETE FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ? +Plan: +SEARCH rcv_roster_transfers USING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=? AND from_member_id=?) + +Query: DELETE FROM rcv_roster_transfers WHERE roster_transfer_id = ? +Plan: +SEARCH rcv_roster_transfers USING INTEGER PRIMARY KEY (rowid=?) + Query: DELETE FROM received_probes WHERE created_at <= ? Plan: SEARCH received_probes USING COVERING INDEX idx_received_probes_created_at (created_at StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = +toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -689,7 +689,7 @@ toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayN businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers, publicMemberCount} - in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri, groupKeys} + in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupKeys} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -789,7 +789,7 @@ groupInfoQueryFields = g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, - g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index f23ea8a041..538faf8cac 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -490,6 +490,7 @@ data GroupInfo = GroupInfo uiThemes :: Maybe UIThemeEntityOverrides, customData :: Maybe CustomData, groupSummary :: GroupSummary, + rosterVersion :: Maybe VersionRoster, membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact, groupKeys :: Maybe GroupKeys @@ -1021,6 +1022,11 @@ newtype MemberKey = MemberKey C.PublicKeyEd25519 deriving (Eq, Show) deriving newtype (StrEncoding) +-- Binary encoding for the roster blob; delegates to the Ed25519 key. +instance Encoding MemberKey where + smpEncode (MemberKey k) = smpEncode k + smpP = MemberKey <$> smpP + instance FromJSON MemberKey where parseJSON = strParseJSON "MemberKey" @@ -1542,11 +1548,38 @@ instance ToJSON InlineFileMode where toJSON = J.String . textEncode toEncoding = JE.text . textEncode +-- Discriminates ordinary chat files from the roster blob file, so the receive +-- completion / cancel paths branch on the type rather than on chat_item_id (note +-- folders and redirects also lack a chat item). +data FileType = FTNormal | FTRoster + deriving (Eq, Show) + +instance TextEncoding FileType where + textEncode = \case + FTNormal -> "normal" + FTRoster -> "roster" + textDecode = \case + "normal" -> Just FTNormal + "roster" -> Just FTRoster + _ -> Nothing + +instance FromField FileType where fromField = fromTextField_ textDecode + +instance ToField FileType where toField = toField . textEncode + +instance FromJSON FileType where + parseJSON = textParseJSON "FileType" + +instance ToJSON FileType where + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode + data RcvFileTransfer = RcvFileTransfer { fileId :: FileTransferId, xftpRcvFile :: Maybe XFTPRcvFile, fileInvitation :: FileInvitation, fileStatus :: RcvFileStatus, + fileType :: FileType, rcvFileInline :: Maybe InlineFileMode, senderDisplayName :: ContactName, chunkSize :: Integer, @@ -2091,6 +2124,12 @@ data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublic pattern VersionChat :: Word16 -> VersionChat pattern VersionChat v = Version v +-- A monotonic per-change counter, not a negotiated protocol version: Int64 rather than the Word16 of +-- Version, so a long-lived high-churn channel cannot wrap and be permanently rejected by relays (v >= cur). +newtype VersionRoster = VersionRoster Int64 + deriving (Eq, Ord, Show) + deriving newtype (FromJSON, ToJSON, FromField, ToField) + -- this newtype exists to have a concise JSON encoding of version ranges in chat protocol messages in the form of "1-2" or just "1" newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRangeChat} deriving (Eq, Show) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index c71f7ce37a..296268b58f 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -11,6 +11,7 @@ import qualified Data.ByteString.Char8 as B import Data.Text (Text) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON) import Simplex.Messaging.Util ((<$?>)) @@ -57,6 +58,12 @@ instance ToJSON GroupMemberRole where toJSON = textToJSON toEncoding = textToEncoding +-- Binary encoding for the roster blob; delegates to the canonical TextEncoding +-- (same member/moderator/admin form JSON and the DB use). GRUnknown round-trips. +instance Encoding GroupMemberRole where + smpEncode = smpEncode . textEncode + smpP = maybe (fail "bad GroupMemberRole") pure . textDecode =<< smpP + data GroupAcceptance = GAAccepted | GAPendingApproval | GAPendingReview deriving (Eq, Show) instance StrEncoding GroupAcceptance where @@ -82,6 +89,7 @@ data RelayStatus = RSNew -- only for owner | RSInvited | RSAccepted + | RSAcknowledgedRoster | RSActive | RSInactive | RSRejected @@ -92,6 +100,7 @@ relayStatusText = \case RSNew -> "new" RSInvited -> "invited" RSAccepted -> "accepted" + RSAcknowledgedRoster -> "acknowledged_roster" RSActive -> "active" RSInactive -> "inactive" RSRejected -> "rejected" @@ -101,6 +110,7 @@ instance TextEncoding RelayStatus where RSNew -> "new" RSInvited -> "invited" RSAccepted -> "accepted" + RSAcknowledgedRoster -> "acknowledged_roster" RSActive -> "active" RSInactive -> "inactive" RSRejected -> "rejected" @@ -108,6 +118,7 @@ instance TextEncoding RelayStatus where "new" -> Just RSNew "invited" -> Just RSInvited "accepted" -> Just RSAccepted + "acknowledged_roster" -> Just RSAcknowledgedRoster "active" -> Just RSActive "inactive" -> Just RSInactive "rejected" -> Just RSRejected diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 442834b244..c955ca1353 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -217,7 +217,7 @@ testCfg = shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, tbqSize = 16, - channelSubscriberRole = GRMember, -- starting role is GRMember to test members sending messages + channelSubscriberRole = GRObserver, confirmMigrations = MCYesUp } diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index ac9e325ed2..a32aa2d07c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -20,13 +20,13 @@ import Control.Monad (forM_, void, when) import Data.Bifunctor (second) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Maybe (fromMaybe, isJust, listToMaybe, maybeToList) +import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Time (UTCTime) import Data.Int (Int64) -import Data.List (intercalate, isInfixOf) +import Data.List (intercalate, isInfixOf, isSuffixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) +import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks) import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) @@ -288,6 +288,18 @@ chatGroupTests = do it "operator allow clears rejection and relay accepts again" testRelayAllowAcceptsAgain it "rejection on channel A does not affect unrelated channel B" testRelayDoesNotRejectUnrelatedChannel it "concurrent fresh invitations both rejected" testRelayRejectRaceConcurrentInvitations + describe "promoted members roster" $ do + it "moderator action verifies via owner-signed roster" testChannelModeratorActionViaRoster + it "removed moderator drops from the roster cache" testChannelRemovedModeratorRefreshesRoster + it "role transitions update the roster (mod <-> admin, admin -> non-roster)" testChannelRoleTransitionsUpdateRoster + it "malicious relay cannot downgrade or re-key a roster-established moderator via XGrpMemNew" testChannelRelayCannotDowngradeRosterMember + it "should add relay to channel with roster (relay caches roster before joinable)" testChannelAddRelayWithRoster + it "roster blob spanning multiple chunks reassembles" testChannelRosterMultipartReassembly + it "corrupted roster blob is rejected on digest mismatch" testChannelRosterDigestMismatchRejected + it "promoted member enters the roster and can post" testChannelPromotedMemberCanPost + it "observer cannot post until promoted" testChannelObserverCannotPost + it "promoted member re-connecting via a new relay is accepted via the roster-pinned key" testChannelPromotedMemberRejoinViaRelay + it "2 relays: multi-chunk roster reassembles per source (no stream interleaving)" testChannelRosterMultiRelayMultipart describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete @@ -8676,6 +8688,20 @@ createChannel1Relay gName owner relay cath dan eve = do forM_ [cath, dan, eve] $ \member -> memberJoinChannel gName [relay] [owner] shortLink fullLink member +-- Promote a fresh channel subscriber (observer default) to member so it can post; the roster bump +-- re-serves to the other (still-unknown) subscribers, who see the change rendered by member id hash. +promoteChannelMember :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> [TestCC] -> IO () +promoteChannelMember gName owner relay member others = do + mName <- userName member + oName <- userName owner + owner ##> ("/mr #" <> gName <> " " <> mName <> " member") + owner <## ("#" <> gName <> ": you changed the role of " <> mName <> " to member (signed)") + concurrentlyN_ $ + [ relay <## ("#" <> gName <> ": " <> oName <> " changed the role of " <> mName <> " from observer to member (signed)"), + member <## ("#" <> gName <> ": " <> oName <> " changed your role from observer to member (signed)") + ] + <> [o <### [EndsWith "from observer to member (signed)"] | o <- others] + setupRelay :: TestCC -> TestCC -> IO String setupRelay owner relay = do rName <- userName relay @@ -8710,7 +8736,7 @@ prepareChannel' relayId gName owner relay = do ] owner ##> ("/show link #" <> gName) - getGroupLinks owner gName GRMember False + getGroupLinks owner gName GRObserver False createChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () createChannel2Relays gName owner relay1 relay2 dan eve frank = do @@ -8741,7 +8767,7 @@ prepareChannel2Relays gName owner relay1 relay2 = do owner <## ("#" <> gName <> ": group link relays updated, current relays:") owner <### [ EndsWith ": active", - EndsWith ": accepted" + Predicate (\l -> ": invited" `isSuffixOf` l || ": accepted" `isSuffixOf` l || ": acknowledged_roster" `isSuffixOf` l) ] owner <## "group link:" void $ getTermLine owner -- consume group link line @@ -8758,7 +8784,7 @@ prepareChannel2Relays gName owner relay1 relay2 = do ] owner ##> ("/show link #" <> gName) - getGroupLinks owner gName GRMember False + getGroupLinks owner gName GRObserver False memberJoinChannel :: String -> [TestCC] -> [TestCC] -> String -> String -> TestCC -> IO () memberJoinChannel gName = memberJoinChannel' gName 1 0 0 0 @@ -8887,6 +8913,9 @@ testChannelsSenderDeduplicateOwn ps = do withNewTestChat ps "eve" eveProfile $ \eve -> do withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath and dan while the relay is online, so their buffered posts replay as members + promoteChannelMember "team" alice bob cath [dan, eve] + promoteChannelMember "team" alice bob dan [cath, eve] -- chat relay bob is offline alice #> "#team 1" @@ -8912,14 +8941,16 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team dan> 6 [>>]" ] cath - <### [ "#team: bob introduced dan (Daniel) in the channel", + <### [ EndsWith "updated to dan", + "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", WithTime "#team dan> 6 [>>]" ] dan - <### [ "#team: bob introduced cath (Catherine) in the channel", + <### [ EndsWith "updated to cath", + "#team: bob introduced cath (Catherine) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", @@ -8927,7 +8958,9 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team cath> 5 [>>]" ] eve - <### [ "#team: bob introduced cath (Catherine) in the channel", + <### [ EndsWith "updated to cath", + EndsWith "updated to dan", + "#team: bob introduced cath (Catherine) in the channel", "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", @@ -8948,11 +8981,13 @@ testChannelLateJoinerReceivesProfile ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] - -- first forward: dan learns cath via prepended XGrpMemNew. + -- first forward: dan resolves cath (roster-known by id hash) on the prepended XGrpMemNew. cath #> "#team hi" bob <# "#team cath> hi" alice <# "#team cath> hi [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hi [>>]" @@ -8988,12 +9023,23 @@ testChannel2RelaysDeduplicateProfile ps = memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink eve + -- promote dan (observer default) so it can post; eve learns dan via the roster (id hash) + alice ##> "/mr #team dan member" + alice <## "#team: you changed the role of dan to member (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of dan from observer to member (signed)", + cath <## "#team: alice changed the role of dan from observer to member (signed)", + dan <## "#team: alice changed your role from observer to member (signed)", + eve <### [EndsWith "from observer to member (signed)"] + ] + -- first forward: both relays prepend XGrpMemNew(dan) for eve; -- second hits xGrpMemNew's "already created via another relay" branch. dan #> "#team hi" bob <# "#team dan> hi" cath <# "#team dan> hi" alice <# "#team dan> hi [>>]" + eve <### [EndsWith "updated to dan"] eve .<## " introduced dan (Daniel) in the channel" eve <# "#team dan> hi [>>]" @@ -9035,6 +9081,7 @@ testChannelLargeProfileFits ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] -- ~14000 chars: profile fits in a singleton batch AND packs -- inline with the forwarded body (exercises the in-body path). @@ -9045,6 +9092,7 @@ testChannelLargeProfileFits ps = cath #> "#team hi" bob <# "#team cath> hi" alice <# "#team cath> hi [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hi [>>]" @@ -9058,6 +9106,8 @@ testChannelMultipleLargeProfiles ps = withNewTestChat ps "dan" danProfile $ \dan -> do withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + promoteChannelMember "team" alice bob dan [cath, eve] -- ~14500 chars each: one rides inline with the body, -- the other spills into a standalone overflow batch. @@ -9079,15 +9129,19 @@ testChannelMultipleLargeProfiles ps = WithTime "#team dan> from dan [>>]" ] cath - <### [ "#team: bob introduced dan (Daniel) in the channel", + <### [ EndsWith "updated to dan", + "#team: bob introduced dan (Daniel) in the channel", WithTime "#team dan> from dan [>>]" ] dan - <### [ "#team: bob introduced cath (Catherine) in the channel", + <### [ EndsWith "updated to cath", + "#team: bob introduced cath (Catherine) in the channel", WithTime "#team cath> from cath [>>]" ] eve - <### [ "#team: bob introduced dan (Daniel) in the channel", + <### [ EndsWith "updated to cath", + EndsWith "updated to dan", + "#team: bob introduced dan (Daniel) in the channel", "#team: bob introduced cath (Catherine) in the channel", WithTime "#team cath> from cath [>>]", WithTime "#team dan> from dan [>>]" @@ -9109,10 +9163,12 @@ testChannelProfileUpdateNoRePrepend ps = (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob memberJoinChannel "team" [bob] [alice] shortLink fullLink cath memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] cath #> "#team hi" bob <# "#team cath> hi" alice <# "#team cath> hi [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hi [>>]" @@ -9138,22 +9194,28 @@ testChannelMultiSendersIndependent ps = withNewTestChat ps "dan" danProfile $ \dan -> do withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + promoteChannelMember "team" alice bob dan [cath, eve] - -- cath posts: dan and eve learn cath via prepended XGrpMemNew + -- cath posts: dan and eve resolve cath on the prepended XGrpMemNew cath #> "#team from cath" bob <# "#team cath> from cath" alice <# "#team cath> from cath [>>]" + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> from cath [>>]" + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> from cath [>>]" - -- dan posts: cath and eve learn dan independently of cath's vector + -- dan posts: cath and eve resolve dan independently of cath's vector dan #> "#team from dan" bob <# "#team dan> from dan" alice <# "#team dan> from dan [>>]" + cath <### [EndsWith "updated to dan"] cath <## "#team: bob introduced dan (Daniel) in the channel" cath <# "#team dan> from dan [>>]" + eve <### [EndsWith "updated to dan"] eve <## "#team: bob introduced dan (Daniel) in the channel" eve <# "#team dan> from dan [>>]" @@ -9174,6 +9236,17 @@ testChannels2RelaysDeliver ps = withNewTestChat ps "frank" frankProfile $ \frank -> do createChannel2Relays "team" alice bob cath dan eve frank + -- promote dan (observer default) so it can send; eve/frank learn dan via the roster + alice ##> "/mr #team dan member" + alice <## "#team: you changed the role of dan to member (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of dan from observer to member (signed)", + cath <## "#team: alice changed the role of dan from observer to member (signed)", + dan <## "#team: alice changed your role from observer to member (signed)", + eve <### [EndsWith "from observer to member (signed)"], + frank <### [EndsWith "from observer to member (signed)"] + ] + alice #> "#team hi" [bob, cath] *<# "#team> hi" [dan, eve, frank] *<# "#team> hi [>>]" @@ -9186,18 +9259,15 @@ testChannels2RelaysDeliver ps = cath <## " + 👍" alice <# "#team dan> > hi" alice <## " + 👍" + eve .<##. ("#team: unknown member ", " updated to dan") eve .<## " introduced dan (Daniel) in the channel" eve <# "#team dan> > hi" eve <## " + 👍" + frank .<##. ("#team: unknown member ", " updated to dan") frank .<## " introduced dan (Daniel) in the channel" frank <# "#team dan> > hi" frank <## " + 👍" - -- remove below if default role is changed to observer - dan #> "#team hey" - [bob, cath] *<# "#team dan> hey" - [alice, eve, frank] *<# "#team dan> hey [>>]" - testChannels2RelaysIncognito :: HasCallStack => TestParams -> IO () testChannels2RelaysIncognito ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -9211,6 +9281,17 @@ testChannels2RelaysIncognito ps = forM_ [eve, frank] $ \member -> memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink member + -- promote dan (observer default) so it can send; eve/frank learn dan via the roster + alice ##> ("/mr #team " <> danIncognito <> " member") + alice <## ("#team: you changed the role of " <> danIncognito <> " to member (signed)") + concurrentlyN_ + [ bob <## ("#team: alice changed the role of " <> danIncognito <> " from observer to member (signed)"), + cath <## ("#team: alice changed the role of " <> danIncognito <> " from observer to member (signed)"), + dan <## "#team: alice changed your role from observer to member (signed)", + eve <### [EndsWith "from observer to member (signed)"], + frank <### [EndsWith "from observer to member (signed)"] + ] + alice #> "#team hi" [bob, cath] *<# "#team> hi" dan ?<# "#team> hi [>>]" @@ -9224,18 +9305,15 @@ testChannels2RelaysIncognito ps = cath <## " + 👍" alice <# ("#team " <> danIncognito <> "> > hi") alice <## " + 👍" + eve .<##. ("#team: unknown member ", (" updated to " <> danIncognito)) eve .<## (" introduced " <> danIncognito <> " in the channel") eve <# ("#team " <> danIncognito <> "> > hi") eve <## " + 👍" + frank .<##. ("#team: unknown member ", (" updated to " <> danIncognito)) frank .<## (" introduced " <> danIncognito <> " in the channel") frank <# ("#team " <> danIncognito <> "> > hi") frank <## " + 👍" - -- remove below if default role is changed to observer - dan ?#> "#team hey" - [bob, cath] *<# ("#team " <> danIncognito <> "> hey") - [alice, eve, frank] *<# ("#team " <> danIncognito <> "> hey [>>]") - testChannelUpdateProfileSigned :: HasCallStack => TestParams -> IO () testChannelUpdateProfileSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9293,7 +9371,7 @@ testChannelLinkAfterProfileUpdate ps = -- late subscriber joins via the same channel link after profile update threadDelay 100000 alice ##> "/show link #my_team" - (shortLink', fullLink') <- getGroupLinks alice "my_team" GRMember False + (shortLink', fullLink') <- getGroupLinks alice "my_team" GRObserver False shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "my_team" [bob] [alice] shortLink' fullLink' dan @@ -9330,7 +9408,7 @@ testChannelLinkAfterWelcomeUpdate ps = -- re-fetch updated link, late subscriber joins threadDelay 100000 alice ##> "/show link #team" - (shortLink', fullLink') <- getGroupLinks alice "team" GRMember False + (shortLink', fullLink') <- getGroupLinks alice "team" GRObserver False shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "team" [bob] [alice] shortLink' fullLink' dan @@ -9367,7 +9445,7 @@ testChannelOwnerKeyAfterLinkUpdate ps = -- Late subscriber joins via the same channel link after profile update. alice ##> "/show link #my_team" - (shortLink', fullLink') <- getGroupLinks alice "my_team" GRMember False + (shortLink', fullLink') <- getGroupLinks alice "my_team" GRObserver False shortLink' `shouldBe` shortLink fullLink' `shouldBe` fullLink memberJoinChannel "my_team" [bob] [alice] shortLink' fullLink' dan @@ -9434,15 +9512,20 @@ testChannelChangeRoleSigned ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- other members discover cath cath #> "#team hello from cath" bob <# "#team cath> hello from cath" concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9463,21 +9546,21 @@ testChannelChangeRoleSigned ps = dan #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) - -- change role of silent member (other members don't know about member) + -- change role of silent member; cath/eve don't know dan via xGrpMemRole, but the + -- subsequent roster apply emits the chat item with dan TOFU-created at the new role threadDelay 1000000 alice ##> "/mr #team dan admin" alice <## "#team: you changed the role of dan to admin (signed)" - bob <## "#team: alice changed the role of dan from member to admin (signed)" concurrentlyN_ - [ dan <## "#team: alice changed your role from member to admin (signed)", - cath <## "error: x.grp.mem.role with unknown member ID", - eve <## "error: x.grp.mem.role with unknown member ID" + [ bob <## "#team: alice changed the role of dan from observer to admin (signed)", + dan <## "#team: alice changed your role from observer to admin (signed)", + cath .<##. ("#team: alice changed the role of ", " from observer to admin (signed)"), + eve .<##. ("#team: alice changed the role of ", " from observer to admin (signed)") ] + -- cath/eve render dan by id hash (unknown to them, roster-TOFU); arrival verified above alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of dan to admin (signed)")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of dan to admin (signed)")]) - cath #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) -- now new chat item dan #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) - eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) -- now new chat item testChannelBlockMemberSigned :: HasCallStack => TestParams -> IO () testChannelBlockMemberSigned ps = @@ -9488,6 +9571,9 @@ testChannelBlockMemberSigned ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- other members discover cath threadDelay 1000000 cath #> "#team hello from cath" @@ -9495,9 +9581,11 @@ testChannelBlockMemberSigned ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9543,6 +9631,224 @@ testChannelBlockMemberSigned ps = r2 `shouldStartWith` "blocked" r2 `shouldEndWith` "(signed)" +checkMemberRow :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO () +checkMemberRow cc name expectedRole = do + roles <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text] + map (\(Only r) -> r) roles `shouldBe` maybeToList expectedRole + +testChannelModeratorActionViaRoster :: HasCallStack => TestParams -> IO () +testChannelModeratorActionViaRoster ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + forM_ [cath, dan, eve] $ \member -> + memberJoinChannel "team" [bob] [alice] shortLink fullLink member + + -- promote dan (observer default) so it can post; cath and eve then discover dan + threadDelay 1000000 + promoteChannelMember "team" alice bob dan [cath, eve] + dan #> "#team hello from dan" + bob <# "#team dan> hello from dan" + concurrentlyN_ + [ alice <# "#team dan> hello from dan [>>]", + do + cath <### [EndsWith "updated to dan"] + cath <## "#team: bob introduced dan (Daniel) in the channel" + cath <# "#team dan> hello from dan [>>]", + do + eve <### [EndsWith "updated to dan"] + eve <## "#team: bob introduced dan (Daniel) in the channel" + eve <# "#team dan> hello from dan [>>]" + ] + + -- cath promoted observer -> moderator; dan/eve learn cath via the roster re-serve + -- (no name yet -> rendered by member id hash) + threadDelay 1000000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)", + dan <### [EndsWith "to moderator (signed)"], + eve <### [EndsWith "to moderator (signed)"] + ] + + -- cath (moderator) blocks dan; profile prepend carries cath's full profile to dan/eve + threadDelay 1000000 + cath ##> "/block for all #team dan" + cath <## "#team: you blocked dan (signed)" + bob <## "#team: cath blocked dan (signed)" + alice <## "#team: cath blocked dan (signed)" + eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <## "#team: cath blocked dan (signed)" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + + -- frank joins after the roster update; cached roster gives him cath as moderator. + -- both alice (owner) and cath (mod) receive XGrpMemNew(frank) via introduceInChannel. + -- the roster apply also emits the role-change chat item on frank's side (owner + -- profile may not be loaded yet, so the actor renders by memberId hash) + threadDelay 1000000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank + -- the late joiner learns the roster from the served snapshot (verified below); under the + -- no-broadcast model the apply finds no role change to surface, so no item here + threadDelay 1000000 -- the served roster arrives async + checkMemberRole frank "cath" "moderator" + where + checkMemberRole :: HasCallStack => TestCC -> T.Text -> T.Text -> IO () + checkMemberRole cc name expectedRole = do + roles <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text] + map (\(Only r) -> r) roles `shouldBe` [expectedRole] + +testChannelRemovedModeratorRefreshesRoster :: HasCallStack => TestParams -> IO () +testChannelRemovedModeratorRefreshesRoster ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + forM_ [cath, dan, eve] $ \member -> + memberJoinChannel "team" [bob] [alice] shortLink fullLink member + -- cath promoted observer -> moderator; dan/eve learn cath via the roster (id hash) + threadDelay 1000000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)", + dan <### [EndsWith "to moderator (signed)"], + eve <### [EndsWith "to moderator (signed)"] + ] + threadDelay 1000000 + alice ##> "/rm #team cath" + alice <## "#team: you removed cath from the group (signed)" + bob <## "#team: alice removed cath from the group (signed)" + cath <## "#team: alice removed you from the group (signed)" + cath <## "use /d #team to delete the group" + dan <### [EndsWith "from the group (signed)"] + eve <### [EndsWith "from the group (signed)"] + + -- frank joins after the removal; cached roster has dropped cath + threadDelay 1000000 + memberJoinChannel "team" [bob] [alice] shortLink fullLink frank + threadDelay 100000 + checkMemberRow frank "cath" Nothing + +testChannelRoleTransitionsUpdateRoster :: HasCallStack => TestParams -> IO () +testChannelRoleTransitionsUpdateRoster ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + -- observer -> moderator + threadDelay 100000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" + ] + -- dan joins; cached roster has cath as moderator (learned from the served snapshot, + -- no separate role-change item under the no-broadcast model) + threadDelay 100000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan + threadDelay 1000000 -- the served roster arrives async; wait before reading the applied state + checkMemberRow dan "cath" (Just "moderator") + -- moderator -> admin: dan now knows cath, role event lands cleanly + threadDelay 100000 + alice ##> "/mr #team cath admin" + alice <## "#team: you changed the role of cath to admin (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from moderator to admin (signed)", + cath <## "#team: alice changed your role from moderator to admin (signed)", + dan <## "#team: alice changed the role of cath from moderator to admin (signed)" + ] + -- eve joins; cached roster has cath as admin (learned from the served snapshot) + threadDelay 100000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink eve + threadDelay 1000000 -- the served roster arrives async; wait before reading the applied state + checkMemberRow eve "cath" (Just "admin") + -- admin -> observer (crossing out of roster, since member is now in-roster): roster drops cath + threadDelay 100000 + alice ##> "/mr #team cath observer" + alice <## "#team: you changed the role of cath to observer (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from admin to observer (signed)", + cath <## "#team: alice changed your role from admin to observer (signed)", + dan <## "#team: alice changed the role of cath from admin to observer (signed)", + eve <## "#team: alice changed the role of cath from admin to observer (signed)" + ] + -- frank joins; cath isn't in the roster, so frank has no record of her + threadDelay 100000 + memberJoinChannel "team" [bob] [alice] shortLink fullLink frank + threadDelay 100000 + checkMemberRow frank "cath" Nothing + +testChannelRelayCannotDowngradeRosterMember :: HasCallStack => TestParams -> IO () +testChannelRelayCannotDowngradeRosterMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChatOpts ps (testOpts {coreOptions = testCoreOpts {logLevel = CLLWarning}}) "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink frank + -- promote cath; roster TOFU-creates cath on frank as moderator with the real key + threadDelay 1000000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)", + frank <### [EndsWith "to moderator (signed)"] + ] + threadDelay 100000 + realKey <- getMemberPubKey bob "cath" + -- malicious relay: corrupt bob's local record of cath so its XGrpMemNew dissemination + -- carries a downgraded role + no key + withCCTransaction bob $ \db -> + DB.execute + db + "UPDATE group_members SET member_role = ?, member_pub_key = NULL WHERE local_display_name = ?" + ("member" :: T.Text, "cath" :: T.Text) + -- cath posts; bob prepends XGrpMemNew(cath, member, NULL) to the delivery (frank not yet introduced) + threadDelay 100000 + cath #> "#team hello from cath" + bob <# "#team cath> hello from cath" + concurrentlyN_ + [ alice <# "#team cath> hello from cath [>>]", + do + frank <##. "warning: x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId=" + frank <### [EndsWith "updated to cath"] + frank <## "#team: bob introduced cath (Catherine) in the channel" + frank <# "#team cath> hello from cath [>>]" + ] + threadDelay 100000 + checkMemberRow frank "cath" (Just "moderator") + frankKey <- getMemberPubKey frank "cath" + frankKey `shouldBe` realKey + where + getMemberPubKey :: TestCC -> T.Text -> IO (Maybe ByteString) + getMemberPubKey cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_pub_key FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only (Maybe ByteString)] + case rows of + [Only k] -> pure k + _ -> fail $ "expected one row for " <> T.unpack name + testChannelRemoveMemberSigned :: HasCallStack => TestParams -> IO () testChannelRemoveMemberSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9552,15 +9858,20 @@ testChannelRemoveMemberSigned ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote eve to member (observer default) so it can post + promoteChannelMember "team" alice bob eve [cath, dan] + -- other members discover eve eve #> "#team hello from eve" bob <# "#team eve> hello from eve" concurrentlyN_ [ alice <# "#team eve> hello from eve [>>]", do + dan <### [EndsWith "updated to eve"] dan <## "#team: bob introduced eve (Eve) in the channel" dan <# "#team eve> hello from eve [>>]", do + cath <### [EndsWith "updated to eve"] cath <## "#team: bob introduced eve (Eve) in the channel" cath <# "#team eve> hello from eve [>>]" ] @@ -9733,6 +10044,9 @@ testChannelSubscriberLeave ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- other members discover cath threadDelay 1000000 cath #> "#team hello from cath" @@ -9740,9 +10054,11 @@ testChannelSubscriberLeave ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9968,6 +10284,9 @@ testChannelSubscriberProfileUpdate ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote dan to member early (observer default) so its role-change item precedes the messages + promoteChannelMember "team" alice bob dan [cath, eve] + -- enable support and create support chat for cath (but not dan) threadDelay 1000000 alice ##> "/set support #team on" @@ -9987,6 +10306,9 @@ testChannelSubscriberProfileUpdate ps = (dan "#team hello from cath" @@ -9994,9 +10316,11 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -10026,9 +10350,8 @@ testChannelSubscriberProfileUpdate ps = cath #$> ("/_get chat #1 count=2", chat, [(1, "hello from cath"), (1, "hello from kate")]) -- verify profiles are updated correctly forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alice", "bob", "kate", "dan", "eve"] - cath `hasContactProfiles` ["alice", "bob", "kate"] dan `hasContactProfiles` ["alice", "bob", "kate", "dan"] - eve `hasContactProfiles` ["alice", "bob", "kate", "eve"] + -- cath/eve also know dan by id hash now (roster-learned before dan posts); not asserted -- previously silent subscriber updates profile -- dan has no support chat -> no profile update item created @@ -10040,9 +10363,11 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team dave> hello from dave [>>]", do + eve <### [EndsWith "updated to dave"] eve <## "#team: bob introduced dave in the channel" eve <# "#team dave> hello from dave [>>]", do + cath <### [EndsWith "updated to dave"] cath <## "#team: bob introduced dave in the channel" cath <# "#team dave> hello from dave [>>]" ] @@ -10128,6 +10453,250 @@ testChannelAddRelay ps = [bob, cath] *<# "#team> hello" [dan, eve] *<# "#team> hello [>>]" +testChannelAddRelayWithRoster :: HasCallStack => TestParams -> IO () +testChannelAddRelayWithRoster ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "dan" danProfile $ \dan -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "eve" eveProfile $ \_eve -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + + -- promote cath observer -> moderator: the roster is created (bob caches it) + threadDelay 100000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" + ] + threadDelay 100000 + + -- add dan as a 2nd relay; with a roster present it must cache the roster and ack + -- (XGrpRosterAck) before alice publishes it as joinable + dan ##> "/ad" + (danSLink, _cLink) <- getContactLinks dan True + alice ##> ("/relays name=dan " <> danSLink) + alice <## "ok" + alice ##> "/_add relays #1 2" + alice <## "#team: group relays:" + alice <## " - relay id 1: active" + alice <## " - relay id 2: invited" + concurrentlyN_ + [ do + alice <## "#team: group link relays updated, current relays:" + alice + <### [ " - relay id 1: active", + " - relay id 2: active" + ] + alice <## "group link:" + void $ getTermLine alice, + dan <## "#team: you joined the group as relay" + ] + + -- cath (an existing member) connects to the new relay and is attached to her roster + -- record, kept as moderator (the relay learned cath from the cached roster snapshot, so + -- it surfaces no role-change item for her) + concurrentlyN_ + [ do + cath <## "#team: joining the group (connecting to relay dan)..." + cath <## "#team: you joined the group (connected to relay dan)", + dan + <### [ EndsWith "accepting request to join group #team...", + EndsWith "is connected" + ] + ] + + threadDelay 100000 + -- the new relay holds the roster (cath is moderator) and learns her name when she connects + checkMemberRow dan "cath" (Just "moderator") + +testChannelRosterMultipartReassembly :: HasCallStack => TestParams -> IO () +testChannelRosterMultipartReassembly ps = + withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatCfgOpts ps cfg testOpts "cath" cathProfile $ \cath -> + withNewTestChatCfgOpts ps cfg testOpts "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" + ] + threadDelay 100000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan + -- dan reassembles the multi-chunk roster from the served snapshot (arrives async) + threadDelay 1000000 + checkMemberRow dan "cath" (Just "moderator") + where + cfg = testCfg {fileChunkSize = 30} + +testChannelRosterDigestMismatchRejected :: HasCallStack => TestParams -> IO () +testChannelRosterDigestMismatchRejected ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)", + cath <## "#team: alice changed your role from observer to moderator (signed)" + ] + threadDelay 100000 + -- corrupt the relay's stored blob (same length, different content) so its digest no + -- longer matches the signed header (DB-agnostic: read it, overwrite with zeroed bytes) + withCCTransaction bob $ \db -> do + rows <- DB.query_ db "SELECT roster_blob FROM groups WHERE roster_blob IS NOT NULL" :: IO [Only (Binary ByteString)] + forM_ rows $ \(Only (Binary blob)) -> + DB.execute db "UPDATE groups SET roster_blob = ? WHERE roster_blob IS NOT NULL" (Only (Binary (B.replicate (B.length blob) '\NUL'))) + -- frank joins; bob re-serves the valid header with the corrupted blob, frank rejects it + threadDelay 100000 + memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank + threadDelay 1000000 + -- the rejected roster never elevates cath: the intro caps her to the channel default, so she + -- stays observer (not moderator), and the version must not advance to the corrupted roster's version 1 + checkMemberRow frank "cath" (Just "observer") + checkRosterNotApplied frank + where + -- the version is the second guarantee (the role is asserted above): frank holds exactly the team + -- group with no roster applied, so roster_version is NULL - it never advanced to the corrupted version 1 + checkRosterNotApplied :: HasCallStack => TestCC -> IO () + checkRosterNotApplied cc = do + vs <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT roster_version FROM groups" :: IO [Only (Maybe Int64)] + map (\(Only v) -> v) vs `shouldBe` [Nothing] + +testChannelPromotedMemberCanPost :: HasCallStack => TestParams -> IO () +testChannelPromotedMemberCanPost ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + -- promote cath to member: cath enters the owner-signed roster (dan learns cath by id hash) + promoteChannelMember "team" alice bob cath [dan] + -- the promoted member can now post; dan resolves cath on the first forward + cath #> "#team hi from cath" + bob <# "#team cath> hi from cath" + alice <# "#team cath> hi from cath [>>]" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi from cath [>>]" + checkMemberRow dan "cath" (Just "member") + +testChannelObserverCannotPost :: HasCallStack => TestParams -> IO () +testChannelObserverCannotPost ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + -- cath is an observer (default): its own post is rejected locally and never reaches the relay + cath ##> "#team observer attempt" + cath <## "#team: you don't have permission to send messages" + -- promote cath to member; the post is now accepted and delivered, dan resolves cath + promoteChannelMember "team" alice bob cath [dan] + cath #> "#team member post" + bob <# "#team cath> member post" + alice <# "#team cath> member post [>>]" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> member post [>>]" + +testChannelPromotedMemberRejoinViaRelay :: HasCallStack => TestParams -> IO () +testChannelPromotedMemberRejoinViaRelay ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "dan" danProfile $ \dan -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + -- promote cath to member: cath enters the owner-signed roster with her pinned key + threadDelay 100000 + promoteChannelMember "team" alice bob cath [] + threadDelay 100000 + -- add dan as a 2nd relay; it caches the roster (incl. member cath) before joinable + dan ##> "/ad" + (danSLink, _cLink) <- getContactLinks dan True + alice ##> ("/relays name=dan " <> danSLink) + alice <## "ok" + alice ##> "/_add relays #1 2" + alice <## "#team: group relays:" + alice <## " - relay id 1: active" + alice <## " - relay id 2: invited" + concurrentlyN_ + [ do + alice <## "#team: group link relays updated, current relays:" + alice + <### [ " - relay id 1: active", + " - relay id 2: active" + ] + alice <## "group link:" + void $ getTermLine alice, + dan <## "#team: you joined the group as relay" + ] + -- cath (a promoted member) connects to the new relay; the widened join gate + -- (verifyKey over the roster-pinned key) accepts her and keeps her as member + concurrentlyN_ + [ do + cath <## "#team: joining the group (connecting to relay dan)..." + cath <## "#team: you joined the group (connected to relay dan)", + dan + <### [ EndsWith "accepting request to join group #team...", + EndsWith "is connected" + ] + ] + threadDelay 100000 + checkMemberRow dan "cath" (Just "member") + +testChannelRosterMultiRelayMultipart :: HasCallStack => TestParams -> IO () +testChannelRosterMultiRelayMultipart ps = + withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatCfgOpts ps cfg relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChatCfgOpts ps cfg testOpts "dan" danProfile $ \dan -> + withNewTestChatCfgOpts ps cfg testOpts "eve" eveProfile $ \eve -> + withNewTestChatCfgOpts ps cfg testOpts "frank" frankProfile $ \frank -> do + createChannel2Relays "team" alice bob cath dan eve frank + + -- promote eve to moderator: the owner-signed roster broadcasts through BOTH relays to dan and + -- frank (each connected to both). At fileChunkSize=30 the blob spans multiple chunks, so each + -- member receives two interleaved multi-chunk streams (one per relay) for the same roster. + threadDelay 1000000 + alice ##> "/mr #team eve moderator" + alice <## "#team: you changed the role of eve to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of eve from observer to moderator (signed)", + cath <## "#team: alice changed the role of eve from observer to moderator (signed)", + eve <## "#team: alice changed your role from observer to moderator (signed)", + dan <### [EndsWith "to moderator (signed)"], + frank <### [EndsWith "to moderator (signed)"] + ] + threadDelay 1000000 -- let both relays' interleaved multipart streams settle + + -- per-source transfers keep the streams independent, so each member reassembles the blob and pins + -- eve as the single moderator WITH her owner-attested key (role + key both come from the blob) + checkOneModeratorWithKey dan + checkOneModeratorWithKey frank + where + cfg = testCfg {fileChunkSize = 30} + checkOneModeratorWithKey cc = do + rows <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT member_pub_key FROM group_members WHERE member_role = 'moderator'" :: IO [Only (Maybe ByteString)] + map (\(Only k) -> isJust k) rows `shouldBe` [True] + testChannelRemoveRelay :: HasCallStack => TestParams -> IO () testChannelRemoveRelay ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -10708,42 +11277,48 @@ testChannelMessageFile ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do createChannel1Relay "team" alice bob cath dan eve - + -- the roster arrives as a file before this one; Postgres assigns it a new id and does not + -- reuse it on delete (SQLite does), so the received message file is id 2 here, 1 on SQLite. +#if defined(dbPostgres) + let rcvFileId = 2 :: Int +#else + let rcvFileId = 1 :: Int +#endif -- owner sends file as channel message alice #> "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" alice <## "completed uploading file 1 (test.jpg) for #team" bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" + bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it") concurrentlyN_ [ do cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - cath <## "use /fr 1 [/ | ] to receive it [>>]", + cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - dan <## "use /fr 1 [/ | ] to receive it [>>]", + dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - eve <## "use /fr 1 [/ | ] to receive it [>>]" + eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]") ] -- all members receive the file concurrently src <- B.readFile "./tests/fixtures/test.jpg" concurrentlyN_ - [ receiveFile bob "bob" src, - receiveFile cath "cath" src, - receiveFile dan "dan" src, - receiveFile eve "eve" src + [ receiveFile bob "bob" rcvFileId src, + receiveFile cath "cath" rcvFileId src, + receiveFile dan "dan" rcvFileId src, + receiveFile eve "eve" rcvFileId src ] where - receiveFile cc name src = do + receiveFile cc name fileId src = do let path = "./tests/tmp/test_" <> name <> ".jpg" - cc ##> ("/fr 1 " <> path) + cc ##> ("/fr " <> show fileId <> " " <> path) cc - <### [ ConsoleString ("saving file 1 from #team to " <> path), - "started receiving file 1 (test.jpg) from #team" + <### [ ConsoleString ("saving file " <> show fileId <> " from #team to " <> path), + ConsoleString ("started receiving file " <> show fileId <> " (test.jpg) from #team") ] - cc <## "completed receiving file 1 (test.jpg) from #team" + cc <## ("completed receiving file " <> show fileId <> " (test.jpg) from #team") B.readFile path >>= (`shouldBe` src) testChannelMessageFileCancel :: HasCallStack => TestParams -> IO () @@ -10754,33 +11329,37 @@ testChannelMessageFileCancel ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do createChannel1Relay "team" alice bob cath dan eve - +#if defined(dbPostgres) + let rcvFileId = 2 :: Int +#else + let rcvFileId = 1 :: Int +#endif -- owner sends file as channel message alice #> "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" alice <## "completed uploading file 1 (test.jpg) for #team" bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" + bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it") concurrentlyN_ [ do cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - cath <## "use /fr 1 [/ | ] to receive it [>>]", + cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - dan <## "use /fr 1 [/ | ] to receive it [>>]", + dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - eve <## "use /fr 1 [/ | ] to receive it [>>]" + eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]") ] -- owner cancels file alice ##> "/fc 1" alice <## "cancelled sending file 1 (test.jpg) to bob" - bob <## "team cancelled sending file 1 (test.jpg)" + bob <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)") concurrentlyN_ - [ cath <## "team cancelled sending file 1 (test.jpg)", - dan <## "team cancelled sending file 1 (test.jpg)", - eve <## "team cancelled sending file 1 (test.jpg)" + [ cath <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)"), + dan <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)"), + eve <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)") ] testChannelMessageQuote :: HasCallStack => TestParams -> IO () @@ -10797,6 +11376,9 @@ testChannelMessageQuote ps = bob <# "#team> hello from channel" [cath, dan, eve] *<# "#team> hello from channel [>>]" + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- member quotes channel message cath `send` "> #team (hello from) replying to channel" cath <# "#team > hello from channel" @@ -10808,10 +11390,12 @@ testChannelMessageQuote ps = alice <# "#team cath> > hello from channel [>>]" alice <## " replying to channel [>>]", do + dan <### [EndsWith "updated to cath"] dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hello from channel [>>]" dan <## " replying to channel [>>]", do + eve <### [EndsWith "updated to cath"] eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hello from channel [>>]" eve <## " replying to channel [>>]" @@ -10925,43 +11509,47 @@ testChannelOwnerFileTransferAsMember ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do createChannel1Relay "team" alice bob cath dan eve - +#if defined(dbPostgres) + let rcvFileId = 2 :: Int +#else + let rcvFileId = 1 :: Int +#endif -- owner sends file as member (not as channel) alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" alice <## "completed uploading file 1 (test.jpg) for #team" bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" + bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it") concurrentlyN_ [ do cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - cath <## "use /fr 1 [/ | ] to receive it [>>]", + cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - dan <## "use /fr 1 [/ | ] to receive it [>>]", + dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - eve <## "use /fr 1 [/ | ] to receive it [>>]" + eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]") ] -- all members receive the file src <- B.readFile "./tests/fixtures/test.jpg" concurrentlyN_ - [ receiveFile bob "bob" src, - receiveFile cath "cath" src, - receiveFile dan "dan" src, - receiveFile eve "eve" src + [ receiveFile bob "bob" rcvFileId src, + receiveFile cath "cath" rcvFileId src, + receiveFile dan "dan" rcvFileId src, + receiveFile eve "eve" rcvFileId src ] where - receiveFile cc name src = do + receiveFile cc name fileId src = do let path = "./tests/tmp/test_" <> name <> ".jpg" - cc ##> ("/fr 1 " <> path) + cc ##> ("/fr " <> show fileId <> " " <> path) cc - <### [ ConsoleString ("saving file 1 from alice to " <> path), - "started receiving file 1 (test.jpg) from alice" + <### [ ConsoleString ("saving file " <> show fileId <> " from alice to " <> path), + ConsoleString ("started receiving file " <> show fileId <> " (test.jpg) from alice") ] - cc <## "completed receiving file 1 (test.jpg) from alice" + cc <## ("completed receiving file " <> show fileId <> " (test.jpg) from alice") B.readFile path >>= (`shouldBe` src) testChannelOwnerFileCancelAsMember :: HasCallStack => TestParams -> IO () @@ -10972,34 +11560,38 @@ testChannelOwnerFileCancelAsMember ps = withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do createChannel1Relay "team" alice bob cath dan eve - +#if defined(dbPostgres) + let rcvFileId = 2 :: Int +#else + let rcvFileId = 1 :: Int +#endif -- owner sends file as member (not as channel) alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" alice <# "/f #team ./tests/fixtures/test.jpg" alice <## "use /fc 1 to cancel sending" alice <## "completed uploading file 1 (test.jpg) for #team" bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" - bob <## "use /fr 1 [/ | ] to receive it" + bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it") concurrentlyN_ [ do cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - cath <## "use /fr 1 [/ | ] to receive it [>>]", + cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - dan <## "use /fr 1 [/ | ] to receive it [>>]", + dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"), do eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" - eve <## "use /fr 1 [/ | ] to receive it [>>]" + eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]") ] -- owner cancels file alice ##> "/fc 1" alice <## "cancelled sending file 1 (test.jpg) to bob" - bob <## "alice cancelled sending file 1 (test.jpg)" + bob <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)") concurrentlyN_ - [ cath <## "alice cancelled sending file 1 (test.jpg)", - dan <## "alice cancelled sending file 1 (test.jpg)", - eve <## "alice cancelled sending file 1 (test.jpg)" + [ cath <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)"), + dan <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)"), + eve <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)") ] testChannelReactionAttribution :: HasCallStack => TestParams -> IO () @@ -11163,14 +11755,19 @@ testChannelMemberMessageUpdate ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- member sends a message cath #> "#team hello" bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob introduced cath (Catherine) in the channel" + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob introduced cath (Catherine) in the channel" + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] @@ -11194,14 +11791,19 @@ testChannelMemberMessageDelete ps = withNewTestChat ps "eve" eveProfile $ \eve -> do createChannel1Relay "team" alice bob cath dan eve + -- promote cath to member (observer default) so it can post + promoteChannelMember "team" alice bob cath [dan, eve] + -- member sends a message cath #> "#team hello" bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob introduced cath (Catherine) in the channel" + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob introduced cath (Catherine) in the channel" + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index d76c86495f..2592420d01 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -155,7 +155,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-18\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-19\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -266,13 +266,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -287,7 +287,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -300,7 +300,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpMemConAll (MemberId "\1\2\3\4") it "x.grp.mem.del" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" - #==# XGrpMemDel (MemberId "\1\2\3\4") False + #==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing it "x.grp.leave" $ "{\"v\":\"1\",\"event\":\"x.grp.leave\",\"params\":{}}" ==# XGrpLeave