core: public groups - roster of privileged members (#7017)

This commit is contained in:
spaced4ndy
2026-06-22 10:15:41 +00:00
committed by GitHub
parent 5d3f016627
commit 0e09b38ea6
33 changed files with 2902 additions and 411 deletions
+258 -30
View File
@@ -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