From 8d478b716752a1303fd88809ce8fea832774f65a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:33:12 +0000 Subject: [PATCH 1/5] core: don't create member role change chat item in channels (#7124) --- src/Simplex/Chat/Library/Subscriber.hs | 19 ++++++++++++------- tests/ChatTests/Groups.hs | 14 +++++++------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index c3f61f83b0..829319635f 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3266,7 +3266,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | membershipMemId == memId = applyAtRosterVersion gInfo m rosterVer_ $ let gInfo' = gInfo {membership = membership {memberRole = memRole}} - in changeMemberRole gInfo' membership False (\db -> updateGroupMemberRole db user membership memRole) $ RGEUserRole memRole + in changeMemberRole gInfo' membership False (\db -> updateGroupMemberRole db user membership memRole) (RGEUserRole memRole) True | 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 @@ -3276,11 +3276,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- 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 + in changeMemberRole gInfo member created (\db -> void $ applyMemberKeyRole db member pubKey memRole) gEvent (not $ useRelays' gInfo) -- 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 changeMemberRole gInfo member created (\db -> updateGroupMemberRole db user member memRole) gEvent (not $ useRelays' gInfo) -- 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 @@ -3288,7 +3288,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = GroupMember {memberId = membershipMemId} = membership -- 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 + changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} created applyMember gEvent createItem | senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) = messageError "x.grp.mem.role with insufficient member permissions" $> Nothing | useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner = @@ -3298,9 +3298,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | useRelays' gInfo && not created && fromRole == memRole = pure $ memberEventDeliveryScope member | otherwise = do withStore' applyMember - (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView cInfo ci + (gInfo'', m') <- + if createItem + then do + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci + pure (gInfo'', m') + else pure (gInfo', m) toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole, msgSigned} pure $ memberEventDeliveryScope member diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a32aa2d07c..d637328925 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -9515,6 +9515,8 @@ testChannelChangeRoleSigned ps = -- promote cath to member (observer default) so it can post promoteChannelMember "team" alice bob cath [dan, eve] + threadDelay 1000000 + -- other members discover cath cath #> "#team hello from cath" bob <# "#team cath> hello from cath" @@ -9540,14 +9542,14 @@ testChannelChangeRoleSigned ps = dan <## "#team: alice changed the role of cath from member to admin (signed)", eve <## "#team: alice changed the role of cath from member to admin (signed)" ] + -- chat item is not created for other members alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of cath to admin (signed)")]) - bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")]) cath #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) - 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)")]) + dan #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")]) + eve #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")]) - -- 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 + -- change role of silent member threadDelay 1000000 alice ##> "/mr #team dan admin" alice <## "#team: you changed the role of dan to admin (signed)" @@ -9557,9 +9559,7 @@ testChannelChangeRoleSigned ps = 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)")]) dan #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) testChannelBlockMemberSigned :: HasCallStack => TestParams -> IO () From 4f855e6bbacb48f9e3e3767f459160a2b3677f7e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:55:21 +0000 Subject: [PATCH 2/5] core: support connecting to channels via cli (#7131) --- src/Simplex/Chat/Library/Commands.hs | 21 +++++++++- tests/ChatTests/Groups.hs | 57 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 004d790844..eada7e5a1b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2266,8 +2266,6 @@ processChatCommand cxt nm = \case CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do - -- TODO [relays] member: /c api to support groups with relays - -- TODO - possibly by going through APIPrepareGroup -> APIConnectPreparedGroup (ccLink, plan) <- connectPlan user cLink False Nothing `catchAllErrors` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing Nothing)); _ -> throwError e connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq @@ -4234,8 +4232,27 @@ processChatCommand cxt nm = \case case plan of CPContactAddress (CAPContactViaAddress Contact {contactId}) -> processChatCommand cxt nm $ APIConnectContactViaAddress userId incognito contactId + CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _) + | ACCL SCMContact ccl <- ccLink -> joinChannelViaRelays ccl gld _ -> processChatCommand cxt nm $ APIConnect userId incognito $ Just ccLink | otherwise = pure $ CRConnectionPlan user ccLink plan + where + joinChannelViaRelays :: CreatedLinkContact -> GroupShortLinkData -> CM ChatResponse + joinChannelViaRelays ccl gld = do + GroupInfo {groupId} <- prepareChannelGroup + processChatCommand cxt nm APIConnectPreparedGroup {groupId, incognito, ownerContact = Nothing, msgContent_ = Nothing} + `catchAllErrors` \e -> do + deletePreparedChannel groupId `catchAllErrors` eToView + throwError e + where + prepareChannelGroup = + processChatCommand cxt nm (APIPrepareGroup userId ccl False gld) >>= \case + CRNewPreparedChat _ (AChat SCTGroup (Chat (GroupChat gInfo _) _ _)) -> pure gInfo + _ -> throwChatError $ CEException "joinChannelViaRelays: unexpected response from APIPrepareGroup" + deletePreparedChannel groupId = do + gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId + deleteGroupConnections user gInfo False + withFastStore' $ \db -> deleteGroup db user gInfo invitationRequestPlan :: User -> ConnReqInvitation -> Maybe ContactShortLinkData -> Maybe OwnerVerification -> CM ConnectionPlan invitationRequestPlan user cReq cld ov = do maybe (CPInvitationLink (ILPOk cld ov)) (invitationEntityPlan cld ov) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index d637328925..b667459b6d 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -255,6 +255,8 @@ chatGroupTests = do describe "multiple relays" $ do it "2 relays: should deliver messages to members" testChannels2RelaysDeliver it "should share same incognito profile with all relays" testChannels2RelaysIncognito + it "should connect to channel via /c (CLI)" testConnectChannelCLI + it "should connect to channel via /c incognito (CLI)" testConnectChannelCLIIncognito describe "deliver member profiles via relay" $ do it "late joiner (no prior history) learns sender on first forward" testChannelLateJoinerReceivesProfile it "2 relays: deduplicate member announcement" testChannel2RelaysDeduplicateProfile @@ -8644,6 +8646,61 @@ testSupportPreferenceChannel ps = bob <# "#team (support) alice> yes [>>]" ] +testConnectChannelCLI :: HasCallStack => TestParams -> IO () +testConnectChannelCLI ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, _fullLink) <- prepareChannel2Relays "team" alice bob cath + relayNames <- mapM userName [bob, cath] + mName <- userName dan + mFullName <- showName dan + dan ##> ("/c " <> shortLink) + dan <## "#team: connection started" + concurrentlyN_ $ + [ dan + <### concat + [ [ ConsoleString ("#team: joining the group (connecting to relay " <> rName <> ")..."), + ConsoleString ("#team: you joined the group (connected to relay " <> rName <> ")") + ] + | rName <- relayNames + ] + ] + <> [ do + relay <## (mFullName <> ": accepting request to join group #team...") + relay <## ("#team: " <> mName <> " joined the group") + | relay <- [bob, cath] + ] + <> [alice <### [EndsWith ("introduced " <> mFullName <> " in the channel")]] + +testConnectChannelCLIIncognito :: HasCallStack => TestParams -> IO () +testConnectChannelCLIIncognito ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, _fullLink) <- prepareChannel2Relays "team" alice bob cath + relayNames <- mapM userName [bob, cath] + dan ##> ("/c i " <> shortLink) + danIncognito <- getTermLine dan + dan <## "#team: connection started incognito" + concurrentlyN_ $ + [ dan + <### concat + [ [ ConsoleString ("#team: joining the group (connecting to relay " <> rName <> ")..."), + ConsoleString ("#team: you joined the group (connected to relay " <> rName <> ") incognito as " <> danIncognito) + ] + | rName <- relayNames + ] + ] + <> [ do + relay <## (danIncognito <> ": accepting request to join group #team...") + relay <## ("#team: " <> danIncognito <> " joined the group") + | relay <- [bob, cath] + ] + <> [alice <### [EndsWith ("introduced " <> danIncognito <> " in the channel")]] + testChannels1RelayDeliver :: HasCallStack => TestParams -> IO () testChannels1RelayDeliver ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do From aa9b147aa8ed1bd3cd34fe04bf5a46b35b7cf8cf Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:28:54 +0000 Subject: [PATCH 3/5] ui: show subsriber roles in list; contributor list for subscribers (#7126) --- .../Shared/Views/Chat/ChatInfoToolbar.swift | 9 ++++++++ .../Views/Chat/Group/ChannelMembersView.swift | 19 ++++++++++----- .../Views/Chat/Group/GroupChatInfoView.swift | 2 +- .../simplex/common/views/chat/ChatView.kt | 5 ++++ .../views/chat/group/ChannelMembersView.kt | 23 ++++++++++++++----- .../commonMain/resources/MR/base/strings.xml | 5 +++- 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 00c8d7070b..f825dbeca7 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -131,6 +131,15 @@ public func subscriberCountStr(_ count: Int64) -> String { : String.localizedStringWithFormat(NSLocalizedString("%d subscribers", comment: "channel subscriber count"), count) } +public func ownersContributorsCountStr(_ count: Int, withContributors: Bool) -> String { + if withContributors { + return String.localizedStringWithFormat(NSLocalizedString("%d owners & contributors", comment: "channel members count"), count) + } + return count == 1 + ? String.localizedStringWithFormat(NSLocalizedString("%d owner", comment: "channel owners count"), count) + : String.localizedStringWithFormat(NSLocalizedString("%d owners", comment: "channel owners count"), count) +} + struct ChatInfoToolbar_Previews: PreviewProvider { static var previews: some View { ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift index 44fc302aff..231054fd78 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -21,22 +21,29 @@ struct ChannelMembersView: View { let s = m.wrapped.memberStatus return s != .memLeft && s != .memRemoved && m.wrapped.memberRole != .relay } + .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } + let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1) if groupInfo.isOwner { - let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1) List { Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) { memberRow(GMember(groupInfo.membership), user: true, showRole: true) ForEach(members) { member in - memberRow(member, user: false, showRole: member.wrapped.memberRole >= .owner) + memberRow(member, user: false, showRole: member.wrapped.memberRole >= .member) } } } } else { - let owners = members.filter { $0.wrapped.memberRole >= .owner } + let contributors = members.filter { $0.wrapped.memberRole >= .member && $0.wrapped.memberStatus != .memUnknown } + let contributorCount = contributors.count + (groupInfo.membership.memberRole >= .member ? 1 : 0) + let withContributors = contributors.contains { $0.wrapped.memberRole < .owner } + || groupInfo.membership.memberRole >= .member List { - Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) { - ForEach(owners) { member in - memberRow(member, user: false, showRole: false) + Section(header: Text(ownersContributorsCountStr(contributorCount, withContributors: withContributors)).foregroundColor(theme.colors.secondary)) { + if groupInfo.membership.memberRole >= .member { + memberRow(GMember(groupInfo.membership), user: true, showRole: true) + } + ForEach(contributors) { member in + memberRow(member, user: false, showRole: member.wrapped.memberRole >= .moderator) } } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 5563d79e61..41e24a6ced 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -691,7 +691,7 @@ struct GroupChatInfoView: View { } private func channelMembersButton() -> some View { - let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners" + let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners & contributors" return NavigationLink { ChannelMembersView(chat: chat, groupInfo: groupInfo) .navigationTitle(label) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 6f7c746691..cc9e71354c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1547,6 +1547,11 @@ fun subscriberCountStr(count: Long): String = if (count == 1L) String.format(generalGetString(MR.strings.channel_subscriber_count_singular), count) else String.format(generalGetString(MR.strings.channel_subscriber_count_plural), count) +fun ownersContributorsCountStr(count: Int, withContributors: Boolean): String = + if (withContributors) String.format(generalGetString(MR.strings.channel_owners_contributors_count), count) + else if (count == 1) String.format(generalGetString(MR.strings.channel_owner_count_singular), count) + else String.format(generalGetString(MR.strings.channel_owner_count_plural), count) + @Composable fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) { Row( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index 9f13cf2b19..ef3d8805f4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.ownersContributorsCountStr import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -33,6 +34,7 @@ fun ChannelMembersView( && m.memberStatus != GroupMemberStatus.MemRemoved && m.memberRole != GroupMemberRole.Relay } + .sortedByDescending { it.memberRole } ColumnWithScrollBar { val title = if (groupInfo.isOwner) { @@ -42,8 +44,8 @@ fun ChannelMembersView( } AppBarTitle(title) + val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() if (groupInfo.isOwner) { - val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() SectionView(title = subscriberCountStr(subscriberCount)) { SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { ChannelMemberRow(groupInfo.membership, user = true, showRole = true, isChannel = groupInfo.isChannel) @@ -55,14 +57,23 @@ fun ChannelMembersView( minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING) ) { - ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner, isChannel = groupInfo.isChannel) + ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Member, isChannel = groupInfo.isChannel) } } } } else { - val owners = members.filter { it.memberRole >= GroupMemberRole.Owner } - SectionView(title = generalGetString(MR.strings.channel_members_section_owners)) { - owners.forEachIndexed { index, member -> + val contributors = members.filter { it.memberRole >= GroupMemberRole.Member && it.memberStatus != GroupMemberStatus.MemUnknown } + val contributorCount = contributors.size + if (groupInfo.membership.memberRole >= GroupMemberRole.Member) 1 else 0 + val withContributors = contributors.any { it.memberRole < GroupMemberRole.Owner } || + groupInfo.membership.memberRole >= GroupMemberRole.Member + SectionView(title = ownersContributorsCountStr(contributorCount, withContributors)) { + if (groupInfo.membership.memberRole >= GroupMemberRole.Member) { + SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + ChannelMemberRow(groupInfo.membership, user = true, showRole = true, isChannel = groupInfo.isChannel) + } + Divider() + } + contributors.forEachIndexed { index, member -> if (index > 0) { Divider() } @@ -71,7 +82,7 @@ fun ChannelMembersView( minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING) ) { - ChannelMemberRow(member, user = false, showRole = false, isChannel = groupInfo.isChannel) + ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Moderator, isChannel = groupInfo.isChannel) } } } 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 71c0bf98a4..9262bd9dac 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2976,9 +2976,12 @@ Subscribers - Owners + Owners & contributors %1$d subscriber %1$d subscribers + %1$d owner + %1$d owners + %1$d owners & contributors you From e49a5ec6b5f0df00d340f0f435593f911a9b0664 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:42:01 +0000 Subject: [PATCH 4/5] core: XGrpMemNew checks (#7132) --- src/Simplex/Chat/Library/Internal.hs | 3 + src/Simplex/Chat/Library/Subscriber.hs | 13 ++- .../SQLite/Migrations/chat_query_plans.txt | 13 +++ tests/ChatTests/Groups.hs | 84 ++++++++++++++++++- 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 41f70afda4..c7f44d125d 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1256,6 +1256,9 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes isRosterRole :: GroupMemberRole -> Bool isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin +isPrivilegedRole :: GroupMemberRole -> Bool +isPrivilegedRole r = r >= GRMember + -- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first. -- Runs on the parsed roster blob. validateGroupRoster :: [RosterMember] -> [RosterMember] diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 829319635f..c964df2b3d 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3071,8 +3071,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do - let fromRelay = useRelays' gInfo && isRelay m - unless fromRelay $ checkHostRole m memRole + unless (useRelays' gInfo) $ checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing else @@ -3081,7 +3080,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- 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 + | useRelays' gInfo && isPrivilegedRole (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) @@ -3095,8 +3094,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = 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 -> + -- asserted privileged but NOT roster-established: relay conjuring a privileged member + | useRelays' gInfo && isPrivilegedRole memRole -> messageError "x.grp.mem.new: privileged role not established by roster" $> Nothing | otherwise -> do (updatedMember, gInfo') <- withStore $ \db -> do @@ -3114,8 +3113,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | 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 _ - -- 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 + -- a privileged member absent from the roster is a relay conjuring one + | useRelays' gInfo && isPrivilegedRole 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 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 fb6166f8c9..eefcb8de57 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -7033,6 +7033,11 @@ Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ? +Plan: +SCAN m USING COVERING INDEX idx_group_members_user_id_local_display_name +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) + Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) @@ -7233,6 +7238,10 @@ Query: SELECT max(active_order) FROM users Plan: SEARCH users +Query: SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1 +Plan: +SCAN group_members + Query: SELECT member_pub_key FROM group_members WHERE local_display_name = ? Plan: SCAN group_members @@ -7253,6 +7262,10 @@ Query: SELECT member_role FROM group_members WHERE local_display_name = ? Plan: SCAN group_members +Query: SELECT member_role, member_pub_key FROM group_members WHERE local_display_name = ? +Plan: +SCAN group_members + Query: SELECT member_status FROM group_members WHERE local_display_name = ? Plan: SCAN group_members diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index b667459b6d..51fe8c54aa 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3,6 +3,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -16,30 +17,37 @@ import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) +import Control.Concurrent.STM (atomically) import Control.Monad (forM_, void, when) +import Control.Monad.Except (runExceptT) import Data.Bifunctor (second) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, isJust, maybeToList) -import Data.Time (UTCTime) +import Data.Time (UTCTime, getCurrentTime) import Data.Int (Int64) 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 (..), ChatLogLevel (..), defaultChatHooks) +import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks) import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) +import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) +import Simplex.Messaging.Agent (sendMessages, vrValue) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) +import Simplex.Messaging.Protocol (MsgFlags (..)) import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Version @@ -295,6 +303,7 @@ chatGroupTests = do 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 "malicious relay cannot forge a privileged member via XGrpMemNew forwarded as the owner" testChannelRelayCannotForgePrivilegedMember 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 @@ -9371,6 +9380,13 @@ testChannels2RelaysIncognito ps = frank <# ("#team " <> danIncognito <> "> > hi") frank <## " + 👍" + alice `hasContactProfiles` ["alice", "bob", "cath", T.pack danIncognito, "eve", "frank"] + bob `hasContactProfiles` ["alice", "bob", T.pack danIncognito, "eve", "frank"] + cath `hasContactProfiles` ["alice", "cath", T.pack danIncognito, "eve", "frank"] + dan `hasContactProfiles` ["alice", "bob", "cath", "dan", T.pack danIncognito] + eve `hasContactProfiles` ["alice", "bob", "cath", T.pack danIncognito, "eve"] + frank `hasContactProfiles` ["alice", "bob", "cath", T.pack danIncognito, "frank"] + testChannelUpdateProfileSigned :: HasCallStack => TestParams -> IO () testChannelUpdateProfileSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9906,6 +9922,68 @@ testChannelRelayCannotDowngradeRosterMember ps = [Only k] -> pure k _ -> fail $ "expected one row for " <> T.unpack name +testChannelRelayCannotForgePrivilegedMember :: HasCallStack => TestParams -> IO () +testChannelRelayCannotForgePrivilegedMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 1000000 + -- the forged attribution only resolves to a privileged author if the victim already holds the + -- owner at GROwner (established via the group link on join) - this documents and guards that premise + checkMemberRow cath "alice" (Just "owner") + ownerMemId <- ownerMemberId bob + connId <- relayConnIdToMember bob "cath" + -- the malicious relay forges the announcement, choosing the new member's role and signing key + g <- C.newRandom + kp <- atomically $ C.generateKeyPair g + ts <- getCurrentTime + let ChatController {smpAgent = bobAgent} = chatController bob + attackerPub = fst kp :: C.PublicKeyEd25519 + forgedMemId = MemberId "forgedadmin1" + forgedProfile = (aliceProfile :: Profile) {displayName = "forgery", fullName = "Forgery"} + memInfo = + MemberInfo + { memberId = forgedMemId, + memberRole = GRAdmin, + v = Nothing, + profile = forgedProfile, + memberKey = Just (MemberKey attackerPub) + } + chatMsg = ChatMessage chatInitialVRange Nothing (XGrpMemNew memInfo Nothing) + fwd = GrpMsgForward (FwdMember ownerMemId "alice") ts + body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)] + sent <- runExceptT $ sendMessages bobAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] + either (fail . show) (const $ pure ()) sent + -- secure: the victim rejects the forged privileged announcement instead of storing it + cath <##. "error: x.grp.mem.new: privileged member not established by roster" + forged <- forgedMemberRows cath "forgery" + forged `shouldBe` [] + where + ownerMemberId :: TestCC -> IO MemberId + ownerMemberId cc = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1" (Only ("owner" :: T.Text)) :: IO [Only ByteString] + case rows of + [Only mid] -> pure (MemberId mid) + _ -> fail "expected exactly one owner member on the relay" + relayConnIdToMember :: TestCC -> T.Text -> IO ByteString + relayConnIdToMember cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query + db + "SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ?" + (Only name) :: + IO [Only ByteString] + case rows of + (Only connId : _) -> pure connId + _ -> fail $ "no relay connection to member " <> T.unpack name + forgedMemberRows :: TestCC -> T.Text -> IO [(T.Text, Maybe ByteString)] + forgedMemberRows cc name = + withCCTransaction cc $ \db -> + DB.query db "SELECT member_role, member_pub_key FROM group_members WHERE local_display_name = ?" (Only name) + testChannelRemoveMemberSigned :: HasCallStack => TestParams -> IO () testChannelRemoveMemberSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice -> From 66e6233e78b54ac38ade99dbc66c5a630e9462f1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 26 Jun 2026 07:35:19 +0100 Subject: [PATCH 5/5] core: 7.0.0.6 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b4487dcf73..f8c599c80b 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 7.0.0.5 +version: 7.0.0.6 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat