Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2026-06-26 07:35:43 +01:00
12 changed files with 248 additions and 41 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
+19 -2
View File
@@ -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)
+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]
+18 -14
View File
@@ -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
@@ -3266,7 +3265,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 +3275,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 +3287,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 +3297,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
@@ -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
+145 -10
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
@@ -255,6 +263,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
@@ -293,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
@@ -8644,6 +8655,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
@@ -9314,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 ->
@@ -9515,6 +9588,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 +9615,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 +9632,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 ()
@@ -9849,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 ->