From 0773ccd057ebf299a14fd2397821ac5021bb1e92 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:49:54 +0000 Subject: [PATCH] core: signed XMember in public group (#7036) --- src/Simplex/Chat/Library/Commands.hs | 38 +++---- src/Simplex/Chat/Library/Internal.hs | 60 ++++++++--- src/Simplex/Chat/Library/Subscriber.hs | 144 ++++++++++++++++++------- src/Simplex/Chat/Protocol.hs | 18 +++- src/Simplex/Chat/Store/Groups.hs | 39 +++---- src/Simplex/Chat/Store/Messages.hs | 5 +- tests/ChatTests/Groups.hs | 75 +++++++++++-- 7 files changed, 269 insertions(+), 110 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b1183127a5..78af15c837 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3594,13 +3594,18 @@ processChatCommand vr nm = \case where cReqHash1 = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} cReqHash2 = contactCReqHash $ CRContactUri crData {crScheme = simplexChat} + -- relay-group joins (only via connectToRelay) carry the target relay member in preparedEntity_; + -- its memberId binds the join signature so a sibling relay can't replay it + relayMemberId_ = case preparedEntity_ of + Just (PCEGroup gInfo m) | useRelays' gInfo -> Just (memberId' m) + _ -> Nothing joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" -- TODO [relays] member: refactor joinContact and up avoiding parallel ifs, xContactId is not used xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ PQSupportOn + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile connect' groupLinkId xContactId_ gInfo_ = do let inGroup = isJust groupLinkId @@ -3615,7 +3620,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile_ groupLinkId subMode chatV pqSup - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = @@ -3630,7 +3635,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup - void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup + void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing Nothing pqSup ct' <- withStore $ \db -> getContact db vr user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of @@ -3639,7 +3644,7 @@ processChatCommand vr nm = \case xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing PQSupportOn + void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing Nothing PQSupportOn ct' <- withStore $ \db -> getContact db vr user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" @@ -3651,13 +3656,14 @@ processChatCommand vr nm = \case r <- tryAllErrors $ do (fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- getShortLinkConnReq nm user relayLink relayLinkData_ <- liftIO $ decodeLinkUserData cData - case (relayLinkData_, linkEntityId) of - (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> + relayMemberId <- case (relayLinkData_, linkEntityId) of + (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> do withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p + pure $ MemberId entityId _ -> throwChatError $ CEException "relay link: no relay link data or entity id" let cReq = linkConnReq fd relayLinkToConnect = CCLink cReq (Just relayLink) - void $ connectViaContact user (Just $ PCEGroup gInfo relayMember) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing + void $ connectViaContact user (Just $ PCEGroup gInfo (relayMember {memberId = relayMemberId})) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing relayMember' <- withFastStore $ \db -> getGroupMember db vr user (groupId' gInfo) (groupMemberId' relayMember) pure (relayLink, relayMember', r) syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM () @@ -3693,23 +3699,19 @@ processChatCommand vr nm = \case pure (connId, chatV) mkXContactId :: Maybe XContactId -> CM XContactId mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure - joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> PQSupport -> CM Connection - joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup = do + joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> Maybe MemberId -> PQSupport -> CM Connection + joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup = do -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile let profileToSend = case gInfo_ of Just gInfo_' -> let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' in userProfileInGroup' user allowSimplexLinks incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True - chatEvent <- case gInfo_ of - Just (Just gInfo) | useRelays' gInfo -> do - let GroupInfo {membership = GroupMember {memberId}} = gInfo - memberPubKey <- case groupKeys gInfo of - Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey - Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" - pure $ XMember profileToSend memberId (MemberKey memberPubKey) - _ -> pure $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_ - dm <- encodeConnInfoPQ pqSup chatV chatEvent + dm <- case gInfo_ of + Just (Just gInfo) | useRelays' gInfo -> case relayMemberId_ of + Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend + Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId" + _ -> encodeConnInfoPQ pqSup chatV $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_ subMode <- chatReadVar subscriptionMode void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared ConnJoined diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 548c7b230b..d25645858f 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -58,7 +58,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages -import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchMessages, encodeBinaryBatch, encodeFwdElement) +import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchMessages, encodeBatchElement, encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Operators @@ -927,9 +927,9 @@ acceptContactRequestAsync liftIO $ setCommandConnId db user cmdId connId getContact db vr user contactId -acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember acceptGroupJoinRequestAsync - user + user@User {userId} uclId gInfo@GroupInfo {groupProfile, membership, businessChat} cReqInvId @@ -941,11 +941,18 @@ acceptGroupJoinRequestAsync gAccepted gLinkMemRole incognitoProfile - memberKey_ = do + memberKey_ + existingMem_ = do gVar <- asks random let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted - (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ + -- a roster-established privileged member attaches a connection to its existing record (keeping + -- owner-authoritative role + key); everyone else is created fresh with the group-link role + (groupMemberId, memberId) <- case existingMem_ of + Just m -> do + withStore' $ \db -> updateGroupMemberStatus db userId m initialStatus + pure (groupMemberId' m, memberId' m) + Nothing -> withStore $ \db -> + createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -1160,12 +1167,12 @@ memberIntroEvt gInfo reMember = mRestrictions = memberRestrictions reMember in XGrpMemIntro mInfo mRestrictions --- Forward the cached owner-signed roster verbatim, attributed to the owner who +-- Forward the saved owner-signed roster verbatim, attributed to the owner who -- sent it, so the recipient verifies the owner signature. -forwardCachedRoster :: User -> GroupInfo -> GroupMember -> CM () -forwardCachedRoster user gInfo subscriber = do +forwardGroupRoster :: User -> GroupInfo -> GroupMember -> CM () +forwardGroupRoster user gInfo subscriber = do vr <- chatVersionRange - withStore' (\db -> getCachedGroupRoster db gInfo) >>= \case + withStore' (\db -> getGroupRoster db gInfo) >>= \case Nothing -> pure () Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}) -> forM_ (eitherToMaybe (J.eitherDecodeStrict' signedBody) :: Maybe (ChatMessage 'Json)) $ \chatMsg -> @@ -1187,9 +1194,9 @@ introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn, void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing withStore' $ \db -> setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs] - -- owner intros first so the joiner has the owner profile loaded before applying the cached roster (signed by the owner) + -- owner intros first so the joiner has the owner profile loaded before applying the saved roster (signed by the owner) sendIntros owners - forwardCachedRoster user gInfo subscriber + forwardGroupRoster user gInfo subscriber sendIntros rosterMems withStore' $ \db -> setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced @@ -1659,13 +1666,16 @@ sendFileInline_ FileTransferMeta {filePath, chunkSize} sharedMsgId sendMsg = chSize = fromIntegral chunkSize parseChatMessage :: Connection -> ByteString -> CM (ChatMessage 'Json) -parseChatMessage conn s = do +parseChatMessage conn s = snd <$> parseChatMessage' conn s +{-# INLINE parseChatMessage #-} + +parseChatMessage' :: Connection -> ByteString -> CM (Maybe SignedMsg, ChatMessage 'Json) +parseChatMessage' conn s = case parseChatMessages s of - [msg] -> liftEither . first (ChatError . errType) $ (\(APMsg _ (ParsedMsg _ _ m)) -> checkEncoding m) =<< msg + [msg] -> liftEither . first (ChatError . errType) $ (\(APMsg _ (ParsedMsg _ sm m)) -> (sm,) <$> checkEncoding m) =<< msg _ -> throwChatError $ CEException "parseChatMessage: single message is expected" where errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) -{-# INLINE parseChatMessage #-} getChatScopeInfo :: VersionRangeChat -> User -> GroupChatScope -> CM GroupChatScopeInfo getChatScopeInfo vr user = \case @@ -2067,6 +2077,26 @@ encodeConnInfoPQ pqSup v chatMsgEvent = do _ -> pure connInfo ECMLarge -> throwChatError $ CEException "large info" +-- conn-info wrapped as a signed element, so the receiver can verify the signature over the body +encodeSignedConnInfo :: MsgEncodingI e => MsgSigning -> ChatMsgEvent e -> CM ByteString +encodeSignedConnInfo signing chatMsgEvent = do + vr <- chatVersionRange + let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent} + case encodeChatMessage maxEncodedInfoLength info of + ECMEncoded body -> pure $ encodeBatchElement (Just $ signChatMsgBody signing body) body + ECMLarge -> throwChatError $ CEException "large signed info" + +-- signed XMember for a relay-group join: proves the joiner holds the member key it asserts, and carries +-- viaRelay = the target relay's memberId inside the signed body so a sibling relay can't accept a replay +encodeXMemberConnInfo :: GroupInfo -> MemberId -> Profile -> CM ByteString +encodeXMemberConnInfo GroupInfo {membership = GroupMember {memberId}, groupKeys} relayMemberId profileToSend = + case groupKeys of + Just GroupKeys {publicGroupId, memberPrivKey} -> + let xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey) (Just relayMemberId) + signing = MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey + in encodeSignedConnInfo signing xMemberEvt + Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" + deliverMessage :: Connection -> CMEventTag e -> MsgBody -> MessageId -> CM (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index d65ab7bf39..5ca033a30f 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -105,6 +105,13 @@ import UnliftIO.STM smallGroupsRcptsMemLimit :: Int smallGroupsRcptsMemLimit = 20 +-- Verifies member signatures over CBGroup <> (publicGroupId, memberId) <> signedBody under the given key. +-- signatures is NonEmpty so the verification can't be vacuously true. +verifyGroupSig :: C.PublicKeyEd25519 -> B64UrlByteString -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool +verifyGroupSig key publicGroupId memberId signatures signedBody = + let prefix = smpEncode CBGroup <> smpEncode (publicGroupId, memberId) + in all (\(MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 key) sig (prefix <> signedBody)) signatures + processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () processAgentMessage _ _ (DEL_RCVQS delQs) = toView $ CEvtAgentRcvQueuesDeleted $ L.map rcvQ delQs @@ -893,8 +900,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = GCInviteeMember | isRelay m -> do withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected - gLink <- withStore $ \db -> getGroupLink db user gInfo - setGroupLinkDataAsync user gInfo gLink + -- always send the relay a roster (materializing version 0 for old channels with NULL roster_version); + -- the relay stays RSInvited (unpublishable) until it acks, so no joiner can impersonate a privileged member + gInfo' <- case rosterVersion gInfo of + Just _ -> pure gInfo + Nothing -> do + withStore' $ \db -> setGroupRosterVersion db gInfo (VersionRoster 0) + pure gInfo {rosterVersion = Just (VersionRoster 0)} + sendGroupRosterToRelay user gInfo' m | otherwise -> do (gInfo', mStatus) <- if not (memberPending m) @@ -1066,6 +1079,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' gr verifiedMsg brokerTs + XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck gInfo' m'' ackVer ackErr -- TODO [knocking] why don't we forward these messages? XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward gInfo' Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs @@ -1126,7 +1140,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkSndInlineFTComplete conn msgId updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy) when continued $ do - when (isUserGrpFwdRelay gInfo) $ forwardCachedRoster user gInfo m -- roster ahead of the resumed backlog + when (isUserGrpFwdRelay gInfo) $ forwardGroupRoster user gInfo m -- roster ahead of the resumed backlog sendPendingGroupMessages user gInfo m conn SWITCH qd phase cStats -> do toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) @@ -1179,9 +1193,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CFGetRelayDataJoin -> do -- Update relay member with key, memberId and profile from link relayLinkData_ <- liftIO $ decodeLinkUserData cData - case (relayLinkData_, linkEntityId) of - (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> + relayMemberId <- case (relayLinkData_, linkEntityId) of + (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> do withStore $ \db -> updateRelayMemberData db user m (MemberId entityId) (MemberKey relayKey) p + pure $ MemberId entityId _ -> throwChatError $ CEException "relay link: no relay link data or entity id" case cReq of CRContactUri crData@ConnReqUriData {crClientData} -> do @@ -1194,13 +1209,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} -- Update connection with data derived from cReq, now available after getConnShortLinkAsync withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup - let GroupMember {memberId = membershipMemId} = membership - incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo + let incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo profileToSend = userProfileInGroup user gInfo incognitoProfile - memberPubKey <- case groupKeys gInfo of - Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey - Nothing -> throwChatError $ CEInternalError "no group keys for channel membership" - dm <- encodeConnInfo $ XMember profileToSend membershipMemId (MemberKey memberPubKey) + dm <- encodeXMemberConnInfo gInfo relayMemberId profileToSend subMode <- chatReadVar subscriptionMode void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode CFGetRelayDataAccept -> do @@ -1210,13 +1221,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = relayProfile <- liftIO (decodeLinkUserData cData) >>= \case Just RelayShortLinkData {relayProfile = p} -> pure p Nothing -> throwChatError $ CEException "relay link: no relay link data" - (confId, m', relay) <- withStore $ \db -> do + confId <- withStore $ \db -> do confId <- getRelayConfId db m liftIO $ updateGroupMemberStatus db userId m GSMemAccepted - (m', relay) <- setRelayLinkAccepted db vr user m (MemberKey relayKey) relayProfile - pure (confId, m', relay) + void $ setRelayKey db vr user m (MemberKey relayKey) relayProfile + pure confId allowAgentConnectionAsync user conn confId XOk - toView $ CEvtGroupRelayUpdated user gInfo m' relay else -- TODO [relays] owner: TBC failed RelayStatus? messageError "relay link: relay member ID mismatch" @@ -1224,7 +1234,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = QCONT -> do continued <- continueSending connEntity conn when continued $ do - when (isUserGrpFwdRelay gInfo) $ forwardCachedRoster user gInfo m -- roster ahead of the resumed backlog + when (isUserGrpFwdRelay gInfo) $ forwardGroupRoster user gInfo m -- roster ahead of the resumed backlog sendPendingGroupMessages user gInfo m conn MWARN msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSWarning $ agentSndError err) @@ -1335,10 +1345,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processContactConnMessage :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM () processContactConnMessage agentMsg connEntity conn UserContact {userContactLinkId = uclId, groupId = ucGroupId_} = case agentMsg of REQ invId pqSupport _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo case chatMsgEvent of XContact p xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ pqSupport - XMember p joiningMemberId joiningMemberKey -> memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey + XMember p joiningMemberId joiningMemberKey viaRelay -> memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey viaRelay XInfo p -> profileContactRequest invId chatVRange p Nothing Nothing Nothing pqSupport XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge @@ -1560,7 +1570,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "processContactConnMessage: chat version range incompatible for accepting group join request" | otherwise -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing Nothing (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -1596,19 +1606,37 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Connection {connId = testCId} <- createRelayTestConnection db vr user acId ConnAccepted chatV subMode liftIO $ setCommandConnId db user cmdId testCId -- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays - -- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember) - memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM () - memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey = do + memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Maybe SignedMsg -> Profile -> MemberId -> MemberKey -> Maybe MemberId -> CM () + memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey@(MemberKey joiningKey) viaRelay = do (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId case gLinkInfo_ of Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing (Just joiningMemberKey) + existing_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberByMemberId db vr user gInfo joiningMemberId) + case existing_ of + Just rosterMem + -- a privileged memberId's key is owner-authoritative (the roster); the joiner must prove + -- possession of that exact key, otherwise this is an attempt to impersonate it + | isRosterRole (memberRole' rosterMem) -> + if verifyKey gInfo rosterMem + then acceptJoin gInfo (Just rosterMem) (memberRole' rosterMem) + else messageError "memberJoinRequestViaRelay: rejected join claiming privileged memberId (key mismatch or invalid signature)" + _ -> acceptJoin gInfo Nothing gLinkMemRole + Nothing -> + messageError "memberJoinRequestViaRelay: no group link info for relay link" + where + -- replay defense: the viaRelay == own memberId check (viaRelay is in the signed body); without it a sibling relay could replay a privileged member's signed join + verifyKey gInfo rosterMem = case (signedMsg_, groupKeys gInfo) of + (Just SignedMsg {chatBinding = CBGroup, signatures, signedBody}, Just GroupKeys {publicGroupId}) -> + memberPubKey rosterMem == Just joiningKey + && verifyGroupSig joiningKey publicGroupId joiningMemberId signatures signedBody + && viaRelay == Just (memberId' (membership gInfo)) + _ -> False + acceptJoin gInfo existingMem_ acceptRole = do + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' - Nothing -> - messageError "memberJoinRequestViaRelay: no group link info for relay link" muteEventInChannel :: GroupInfo -> GroupMember -> Bool muteEventInChannel gInfo@GroupInfo {membership} m = @@ -3176,18 +3204,28 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = validRoster = validateGroupRoster roster relayApplyRoster :: CM (Maybe DeliveryJobScope) relayApplyRoster - | maybe False (newVer <=) (rosterVersion gInfo) = Nothing <$ messageWarning "x.grp.roster: not newer than cached version" + | maybe False (newVer <=) (rosterVersion gInfo) = Nothing <$ messageWarning "x.grp.roster: not newer than saved version" | otherwise = case verifiedMsg of - VMSigned _ sm _ -> do - defaultRole <- unknownMemberRole gInfo - results <- withStore $ \db -> do - res <- processRoster db defaultRole - liftIO $ setCachedGroupRoster db gInfo newVer (groupMemberId' author) brokerTs sm - pure res - emitRosterResults results - -- always broadcast on a bump: self-healing, and demotions must reach members - pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + VMSigned _ sm _ -> + tryAllErrors (setRoster sm) >>= \case + Right results -> do + emitRosterResults results + -- ack only while still setting up (own status RSAccepted); a serving relay (RSActive) must not ack roster broadcasts + when (relayOwnStatus gInfo == Just RSAccepted) $ sendRosterAck author newVer Nothing + -- always broadcast on a bump: self-healing, and demotions must reach members + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + Left e -> do + eToView e + when (relayOwnStatus gInfo == Just RSAccepted) $ sendRosterAck author newVer (Just "relay could not save the roster") + pure Nothing VMUnsigned _ -> Nothing <$ messageWarning "x.grp.roster: unsigned roster" + setRoster :: SignedMsg -> CM ([MemberId], [(GroupMember, GroupMemberRole)]) + setRoster sm = do + defaultRole <- unknownMemberRole gInfo + withStore $ \db -> do + res <- processRoster db defaultRole + liftIO $ setGroupRoster db gInfo newVer (groupMemberId' author) brokerTs sm + pure res memberApplyRoster :: CM () memberApplyRoster | maybe False (newVer <) (rosterVersion gInfo) = messageWarning "x.grp.roster: older than accepted version" @@ -3235,6 +3273,28 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gInfo', author', scopeInfo) <- mkGroupChatScope gInfo author createInternalChatItem user (CDGroupRcv gInfo' scopeInfo author') (CIRcvGroupEvent gEvent) (Just brokerTs) toView CEvtMemberRole {user, groupInfo = gInfo', byMember = author', member, fromRole, toRole, msgSigned = Just MSSVerified} + sendRosterAck :: GroupMember -> VersionRoster -> Maybe Text -> CM () + sendRosterAck owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err) + + xGrpRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM () + xGrpRosterAck gInfo m ackVer err = do + relay_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupRelayByGMId db (groupMemberId' m)) + case relay_ of + Just relay@GroupRelay {relayStatus = RSInvited} -> case err of + Nothing + | rosterVersion gInfo == Just ackVer -> do + (relay', gLink) <- withStore $ \db -> do + relay' <- liftIO $ updateRelayStatus db relay RSAccepted + gLink <- getGroupLink db user gInfo + pure (relay', gLink) + setGroupLinkDataAsync user gInfo gLink + toView $ CEvtGroupRelayUpdated user gInfo m relay' + | otherwise -> messageWarning "x.grp.roster.ack: stale version, awaiting ack for the current roster" + Just e -> do + relay' <- withStore' $ \db -> updateRelayStatusFromTo db relay RSInvited RSRejected + toView $ CEvtGroupRelayUpdated user gInfo m relay' + messageError $ "x.grp.roster.ack: relay could not save roster, marked rejected: " <> e + _ -> pure () checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = @@ -3591,8 +3651,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | GroupMember {memberPubKey = Just pubKey, memberId} <- member -> case chatBinding of CBGroup | Just GroupKeys {publicGroupId} <- groupKeys gInfo -> - let prefix = smpEncode chatBinding <> smpEncode (publicGroupId, memberId) - in signed MSSVerified <$ guard (all (\(MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures) + signed MSSVerified <$ guard (verifyGroupSig pubKey publicGroupId memberId signatures signedBody) _ -> signed MSSSignedNoKey <$ guard signatureOptional | otherwise -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag) where @@ -3854,10 +3913,15 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do bucketSize <- asks $ deliveryBucketSize . config senders <- withStore' $ \db -> fmap catMaybes . forM senderGMIds $ \sId -> - fmap eitherToMaybe . runExceptT $ do + fmap (join . eitherToMaybe) . runExceptT $ do sender <- getNonRemovedMemberById db vr user sId - vec <- getMemberRelationsVector db sender - pure (sender, vec) + -- owners are already known to every member (group link + owner-intro in introduceInChannel), + -- so we never disseminate their profile (redundant, and races with joins re-announcing the owner) + if memberRole' sender == GROwner + then pure Nothing + else do + vec <- getMemberRelationsVector db sender + pure $ Just (sender, vec) let missingSenders = length senderGMIds - length senders when (missingSenders > 0) $ logInfo $ "delivery job " <> tshow jobId <> ": " <> tshow missingSenders <> " senders missing; skipping their profile prepend" diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 3f885281c1..a7aa3284d9 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -448,6 +448,11 @@ data MsgSigning = MsgSigning encodeChatBinding :: ChatBinding -> ByteString -> ByteString encodeChatBinding cb bindingData = smpEncode cb <> bindingData +signChatMsgBody :: MsgSigning -> ByteString -> SignedMsg +signChatMsgBody MsgSigning {bindingTag, bindingData, keyRef, privKey} msgBody = + let sig = C.ASignature C.SEd25519 $ C.sign' privKey (encodeChatBinding bindingTag bindingData <> msgBody) + in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig L.:| [], signedBody = msgBody} + data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json @@ -461,7 +466,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XInfo :: Profile -> ChatMsgEvent 'Json XContact :: {profile :: Profile, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json - XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey} -> ChatMsgEvent 'Json + XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey, viaRelay :: Maybe MemberId} -> ChatMsgEvent 'Json XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json @@ -491,6 +496,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpRoster :: GroupRoster -> ChatMsgEvent 'Json + XGrpRosterAck :: VersionRoster -> Maybe Text -> ChatMsgEvent 'Json XGrpMsgForward :: GrpMsgForward -> ChatMessage 'Json -> ChatMsgEvent 'Json XInfoProbe :: Probe -> ChatMsgEvent 'Json XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json @@ -1048,6 +1054,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpPrefs_ :: CMEventTag 'Json XGrpDirectInv_ :: CMEventTag 'Json XGrpRoster_ :: CMEventTag 'Json + XGrpRosterAck_ :: CMEventTag 'Json XGrpMsgForward_ :: CMEventTag 'Json XInfoProbe_ :: CMEventTag 'Json XInfoProbeCheck_ :: CMEventTag 'Json @@ -1109,6 +1116,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpPrefs_ -> "x.grp.prefs" XGrpDirectInv_ -> "x.grp.direct.inv" XGrpRoster_ -> "x.grp.roster" + XGrpRosterAck_ -> "x.grp.roster.ack" XGrpMsgForward_ -> "x.grp.msg.forward" XInfoProbe_ -> "x.info.probe" XInfoProbeCheck_ -> "x.info.probe.check" @@ -1171,6 +1179,7 @@ instance StrEncoding ACMEventTag where "x.grp.prefs" -> XGrpPrefs_ "x.grp.direct.inv" -> XGrpDirectInv_ "x.grp.roster" -> XGrpRoster_ + "x.grp.roster.ack" -> XGrpRosterAck_ "x.grp.msg.forward" -> XGrpMsgForward_ "x.info.probe" -> XInfoProbe_ "x.info.probe.check" -> XInfoProbeCheck_ @@ -1229,6 +1238,7 @@ toCMEventTag msg = case msg of XGrpPrefs _ -> XGrpPrefs_ XGrpDirectInv {} -> XGrpDirectInv_ XGrpRoster _ -> XGrpRoster_ + XGrpRosterAck {} -> XGrpRosterAck_ XGrpMsgForward {} -> XGrpMsgForward_ XInfoProbe _ -> XInfoProbe_ XInfoProbeCheck _ -> XInfoProbeCheck_ @@ -1356,7 +1366,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do reqContent <- opt "content" let requestMsg = (,) <$> reqMsgId <*> reqContent pure XContact {profile, contactReqId, welcomeMsgId, requestMsg} - XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" + XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay" XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" @@ -1389,6 +1399,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences" XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope" XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "roster") + XGrpRosterAck_ -> XGrpRosterAck <$> p "version" <*> opt "error" XGrpMsgForward_ -> do fwdSender <- opt "memberId" >>= \case Just memberId -> FwdMember memberId . fromMaybe "" <$> opt "memberName" @@ -1430,7 +1441,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact {profile, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ["profile" .= profile] - XMember {profile, newMemberId, newMemberKey} -> o ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] + XMember {profile, newMemberId, newMemberKey, viaRelay} -> o $ ("viaRelay" .=? viaRelay) ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] @@ -1462,6 +1473,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpPrefs p -> o ["groupPreferences" .= p] XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq] XGrpRoster GroupRoster {version, roster} -> o ["version" .= version, "roster" .= roster] + XGrpRosterAck version err -> o $ ("error" .=? err) ["version" .= version] XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} msg -> o $ encodeFwdSender fwdSender ["msg" .= msg, "msgTs" .= fwdBrokerTs] where encodeFwdSender = \case diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index b8af85ffe9..d1955d9f59 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -87,15 +87,15 @@ module Simplex.Chat.Store.Groups getGroupRelays, getConnectedGroupRelays, setGroupRosterVersion, - setCachedGroupRoster, - getCachedGroupRoster, + setGroupRoster, + getGroupRoster, setGroupMemberKeyRole, createRelayForOwner, getCreateRelayForMember, createRelayConnection, updateRelayStatus, updateRelayStatusFromTo, - setRelayLinkAccepted, + setRelayKey, setRelayLinkConfId, updateRelayCapabilities, getRelayConfId, @@ -362,6 +362,7 @@ createNewGroup db vr 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 @@ -392,11 +393,11 @@ createNewGroup db vr 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 @@ -423,7 +424,7 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays chatItemTTL = Nothing, uiThemes = Nothing, groupSummary = GroupSummary {currentMembers = 1, publicMemberCount = publicMemberCount_}, - rosterVersion = Nothing, + rosterVersion = rosterVersion0, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing, @@ -1210,7 +1211,7 @@ getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = -- Moderators and admins only, excluding owners and non-current members. -- Used for roster-related paths where owners must not be touched (owners are --- link-anchored), and left/removed members must not appear in the cached roster. +-- link-anchored), and left/removed members must not appear in the saved roster. getGroupRosterMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupRosterMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do filter memberCurrent . map (toContactMember vr user) @@ -1410,9 +1411,9 @@ setGroupRosterVersion db GroupInfo {groupId} v = do currentTs <- getCurrentTime DB.execute db "UPDATE groups SET roster_version = ?, updated_at = ? WHERE group_id = ?" (v, currentTs, groupId) --- Relay caches the verbatim signed roster (parts + sending owner + broker ts) to re-forward to joiners. -setCachedGroupRoster :: DB.Connection -> GroupInfo -> VersionRoster -> GroupMemberId -> UTCTime -> SignedMsg -> IO () -setCachedGroupRoster db GroupInfo {groupId} v ownerGMId brokerTs SignedMsg {chatBinding, signatures, signedBody} = do +-- Relay saves the verbatim signed roster (parts + sending owner + broker ts) to re-forward to joiners. +setGroupRoster :: DB.Connection -> GroupInfo -> VersionRoster -> GroupMemberId -> UTCTime -> SignedMsg -> IO () +setGroupRoster db GroupInfo {groupId} v ownerGMId brokerTs SignedMsg {chatBinding, signatures, signedBody} = do currentTs <- getCurrentTime DB.execute db @@ -1424,8 +1425,8 @@ setCachedGroupRoster db GroupInfo {groupId} v ownerGMId brokerTs SignedMsg {chat |] ((v, ownerGMId, brokerTs, chatBinding) :. (Binary (smpEncode signatures), Binary signedBody, currentTs, groupId)) -getCachedGroupRoster :: DB.Connection -> GroupInfo -> IO (Maybe (GroupMemberId, UTCTime, SignedMsg)) -getCachedGroupRoster db GroupInfo {groupId} = +getGroupRoster :: DB.Connection -> GroupInfo -> IO (Maybe (GroupMemberId, UTCTime, SignedMsg)) +getGroupRoster db GroupInfo {groupId} = (>>= toRoster) <$> maybeFirstRow id @@ -1539,18 +1540,10 @@ updateRelayStatus_ db relayId relayStatus = do currentTs <- getCurrentTime DB.execute db "UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ?" (relayStatus, currentTs, relayId) -setRelayLinkAccepted :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberKey -> Profile -> ExceptT StoreError IO (GroupMember, GroupRelay) -setRelayLinkAccepted db vr user m (MemberKey relayKey) profile = do +setRelayKey :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberKey -> Profile -> ExceptT StoreError IO (GroupMember, GroupRelay) +setRelayKey db vr user m (MemberKey relayKey) profile = do let gmId = groupMemberId' m currentTs <- liftIO getCurrentTime - liftIO $ DB.execute - db - [sql| - UPDATE group_relays - SET relay_status = ?, updated_at = ? - WHERE group_member_id = ? - |] - (RSAccepted, currentTs, gmId) liftIO $ DB.execute db [sql| diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index cdd3185209..5f6a5b2241 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -237,10 +237,7 @@ createNewSndMessage db gVar connOrGroupId chatMsgEvent msgSigning_ encodeMessage case encodeMessage (SharedMsgId sharedMsgId) of ECMLarge -> pure $ Left SELargeMsg ECMEncoded msgBody -> do - let signedMsg_ = signBody <$> msgSigning_ - signBody MsgSigning {bindingTag, bindingData, keyRef, privKey} = - let sig = C.ASignature C.SEd25519 $ C.sign' privKey (encodeChatBinding bindingTag bindingData <> msgBody) - in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig :| [], signedBody = msgBody} + let signedMsg_ = (`signChatMsgBody` msgBody) <$> msgSigning_ createdAt <- getCurrentTime DB.execute db diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 86386b7cb6..131c09cae9 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -23,7 +23,7 @@ import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Time (UTCTime) import Data.Int (Int64) -import Data.List (intercalate, isInfixOf) +import Data.List (intercalate, isInfixOf, isSuffixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks) @@ -268,11 +268,6 @@ chatGroupTests = do it "should update channel preferences (signed)" testChannelUpdatePrefsSigned it "should change member role (signed)" testChannelChangeRoleSigned it "should block member for all (signed)" testChannelBlockMemberSigned - it "moderator action verifies via owner-signed roster" testChannelModeratorActionViaRoster - it "removed moderator drops from the roster cache" testChannelRemovedModeratorRefreshesRoster - it "leaving moderator drops from the roster cache" testChannelLeftModeratorDropsFromRoster - it "role transitions update the roster (mod <-> admin, admin -> non-roster)" testChannelRoleTransitionsUpdateRoster - it "malicious relay cannot downgrade or re-key a roster-established moderator via XGrpMemNew" testChannelRelayCannotDowngradeRosterMember it "should remove member (signed)" testChannelRemoveMemberSigned it "should delete channel (signed)" testChannelDeleteGroupSigned it "should delete channel and clean up relay connections" testChannelDeleteGroupCleanup @@ -291,6 +286,13 @@ chatGroupTests = do it "operator allow clears rejection and relay accepts again" testRelayAllowAcceptsAgain it "rejection on channel A does not affect unrelated channel B" testRelayDoesNotRejectUnrelatedChannel it "concurrent fresh invitations both rejected" testRelayRejectRaceConcurrentInvitations + describe "promoted members roster" $ do + it "moderator action verifies via owner-signed roster" testChannelModeratorActionViaRoster + it "removed moderator drops from the roster cache" testChannelRemovedModeratorRefreshesRoster + it "leaving moderator drops from the roster cache" testChannelLeftModeratorDropsFromRoster + it "role transitions update the roster (mod <-> admin, admin -> non-roster)" testChannelRoleTransitionsUpdateRoster + it "malicious relay cannot downgrade or re-key a roster-established moderator via XGrpMemNew" testChannelRelayCannotDowngradeRosterMember + it "should add relay to channel with roster (relay caches roster before joinable)" testChannelAddRelayWithRoster describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete @@ -8694,7 +8696,7 @@ prepareChannel2Relays gName owner relay1 relay2 = do owner <## ("#" <> gName <> ": group link relays updated, current relays:") owner <### [ EndsWith ": active", - EndsWith ": accepted" + Predicate (\l -> ": invited" `isSuffixOf` l || ": accepted" `isSuffixOf` l) ] owner <## "group link:" void $ getTermLine owner -- consume group link line @@ -10336,6 +10338,65 @@ testChannelAddRelay ps = [bob, cath] *<# "#team> hello" [dan, eve] *<# "#team> hello [>>]" +testChannelAddRelayWithRoster :: HasCallStack => TestParams -> IO () +testChannelAddRelayWithRoster ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "dan" danProfile $ \dan -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "eve" eveProfile $ \_eve -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + + -- promote cath to moderator: the roster is created (bob caches it) + threadDelay 100000 + alice ##> "/mr #team cath moderator" + alice <## "#team: you changed the role of cath to moderator (signed)" + concurrentlyN_ + [ bob <## "#team: alice changed the role of cath from member to moderator (signed)", + cath <## "#team: alice changed your role from member to moderator (signed)" + ] + threadDelay 100000 + + -- add dan as a 2nd relay; with a roster present it must cache the roster and ack + -- (XGrpRosterAck) before alice publishes it as joinable + dan ##> "/ad" + (danSLink, _cLink) <- getContactLinks dan True + alice ##> ("/relays name=dan " <> danSLink) + alice <## "ok" + alice ##> "/_add relays #1 2" + alice <## "#team: group relays:" + alice <## " - relay id 1: active" + alice <## " - relay id 2: invited" + concurrentlyN_ + [ do + alice <## "#team: group link relays updated, current relays:" + alice + <### [ " - relay id 1: active", + " - relay id 2: active" + ] + alice <## "group link:" + void $ getTermLine alice, + dan <## "#team: you joined the group as relay" + ] + + -- cath (an existing member) connects to the new relay and is attached to her roster + -- record, kept as moderator + concurrentlyN_ + [ do + cath <## "#team: joining the group (connecting to relay dan)..." + cath <## "#team: you joined the group (connected to relay dan)", + dan + <### [ EndsWith "changed the role of cath from member to moderator (signed)", + EndsWith "accepting request to join group #team...", + EndsWith "member cath is connected" + ] + ] + + threadDelay 100000 + -- the new relay holds the roster (cath is moderator) before it serves joiners + checkMemberRow dan "cath" (Just "moderator") + testChannelRemoveRelay :: HasCallStack => TestParams -> IO () testChannelRemoveRelay ps = withNewTestChat ps "alice" aliceProfile $ \alice ->