Merge branch 'master' into sh/namespace

This commit is contained in:
Evgeny Poberezkin
2026-06-26 07:36:03 +01:00
11 changed files with 153 additions and 25 deletions
@@ -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: []))
@@ -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)
}
}
}
@@ -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)
@@ -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(
@@ -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)
}
}
}
@@ -2976,9 +2976,12 @@
<!-- ChannelMembersView.kt -->
<string name="channel_members_title_subscribers">Subscribers</string>
<string name="channel_members_section_owners">Owners</string>
<string name="channel_members_section_owners">Owners &amp; contributors</string>
<string name="channel_subscriber_count_singular">%1$d subscriber</string>
<string name="channel_subscriber_count_plural">%1$d subscribers</string>
<string name="channel_owner_count_singular">%1$d owner</string>
<string name="channel_owner_count_plural">%1$d owners</string>
<string name="channel_owners_contributors_count">%1$d owners &amp; contributors</string>
<string name="channel_member_you">you</string>
<!-- ChatRelayView.kt -->
+1 -1
View File
@@ -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
+3
View File
@@ -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]
+6 -7
View File
@@ -3077,8 +3077,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
@@ -3087,7 +3086,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)
@@ -3101,8 +3100,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
@@ -3120,8 +3119,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
@@ -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
+81 -3
View File
@@ -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 ->