From 93831a0f241482f26bf98d1232e417ab0c0e8828 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:44:51 +0000 Subject: [PATCH] core: signed history in channels, auto-sign based on preference (#7257) --- simplex-chat.cabal | 2 + src/Simplex/Chat/Library/Commands.hs | 15 +- src/Simplex/Chat/Library/Internal.hs | 93 ++++++---- src/Simplex/Chat/Library/Subscriber.hs | 3 +- src/Simplex/Chat/Messages.hs | 3 + src/Simplex/Chat/Messages/Batch.hs | 18 ++ src/Simplex/Chat/Store/Messages.hs | 62 +++++-- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260716_signed_history.hs | 29 ++++ .../Store/Postgres/Migrations/chat_schema.sql | 17 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260716_signed_history.hs | 28 +++ .../SQLite/Migrations/chat_query_plans.txt | 54 ++++-- .../Store/SQLite/Migrations/chat_schema.sql | 9 +- tests/ChatTests/Groups.hs | 163 ++++++++++++++++-- 15 files changed, 406 insertions(+), 98 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260716_signed_history.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260716_signed_history.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 3df43c9bcc..462ba72587 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -150,6 +150,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260707_file_digest Simplex.Chat.Store.Postgres.Migrations.M20260714_member_security_code Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description + Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history else exposed-modules: Simplex.Chat.Archive @@ -317,6 +318,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260707_file_digest Simplex.Chat.Store.SQLite.Migrations.M20260714_member_security_code Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description + Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ed1a53b776..75f875de20 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1348,7 +1348,7 @@ processChatCommand cxt nm = \case let doSendDel = memberActive membership && isOwner msgSigned <- if doSendDel - then isJust . signedMsg_ <$> sendGroupMessage' user gInfo recipients XGrpDel + then (\SndMessage {signedMsg_} -> isJust signedMsg_) <$> sendGroupMessage' user gInfo recipients XGrpDel else pure False deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel @@ -2942,7 +2942,7 @@ processChatCommand cxt nm = \case events = L.map (\m@GroupMember {memberId} -> XGrpMemRole memberId newRole (mKey m) rosterVer) memsToChange' recipients = filter memberCurrent members (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients False events - let signed = any (either (const False) (isJust . signedMsg_)) msgs_ + let signed = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch" @@ -2990,7 +2990,7 @@ processChatCommand cxt nm = \case events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems' recipients = filter memberCurrent remainingMems (msgs_, _gsr) <- sendGroupMessages_ user gInfo recipients False events - let msgSigned = any (either (const False) (isJust . signedMsg_)) msgs_ + let msgSigned = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False when (length cis_ /= length blockMems) $ logError "blockMembers: blockMems and cis_ length mismatch" @@ -3083,7 +3083,7 @@ processChatCommand cxt nm = \case let chatScope = toChatScope <$> chatScopeInfo events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete' (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients False events - let signed = any (either (const False) (isJust . signedMsg_)) msgs_ + let signed = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_ itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) skipUnwantedItem = \case Right Nothing -> Nothing @@ -4050,7 +4050,7 @@ processChatCommand cxt nm = \case ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' Nothing) ci] createGroupFeatureChangedItems user cd CISndGroupFeature gInfo gInfo' - pure $ CRGroupUpdated user gInfo gInfo' Nothing (isJust $ signedMsg_ msg) + pure $ CRGroupUpdated user gInfo gInfo' Nothing ((\SndMessage {signedMsg_} -> isJust signedMsg_) msg) checkValidName :: GroupName -> CM () checkValidName displayName = do when (T.null displayName) $ throwChatError CEInvalidDisplayName {displayName, validName = ""} @@ -4747,6 +4747,7 @@ processChatCommand cxt nm = \case assertGroupContentAllowed processComposedMessages where + signMsgs = sign || groupFeatureAllowed SGFSignMessages gInfo allowedRole :: Maybe GroupMemberRole allowedRole = case scope of Nothing -> Just GRAuthor @@ -4771,7 +4772,7 @@ processChatCommand cxt nm = \case (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ - (msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients sign chatMsgEvents + (msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients signMsgs chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) showGroupAsSender itemsData timed_ live when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" @@ -4791,7 +4792,7 @@ processChatCommand cxt nm = \case fileSize <- checkSndFile (if incognitoMembership gInfo then Nothing else localBadge) file (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo recipients fInv' <- - if sign && useRelays' gInfo + if signMsgs && useRelays' gInfo then (\d -> (fInv :: FileInvitation) {fileDigest = Just d}) <$> cryptoFileDigest file else pure fInv pure (Just fInv', Just ciFile) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index e22c4a87f1..28ce796469 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -60,7 +60,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, encodeBatchElement, encodeBinaryBatch, encodeFwdElement) +import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchElements, batchMessages, encodeBatchElement, encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Operators @@ -1315,20 +1315,30 @@ sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternal sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just conn} = when (m `supportsVersion` batchSendVersion) $ do (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100) - (errs', events) <- partitionEithers <$> mapM (tryAllErrors . itemForwardEvents) items + (errs', fwdMsgsByItem) <- partitionEithers <$> mapM (tryAllErrors . itemForwardMsgs) items let errors = map ChatErrorStore errs <> errs' unless (null errors) $ toView $ CEvtChatErrors errors - let events' = concat events - events_ <- case descrEvent_ of - Just descr -> mkEvents <$> withStore' (\db -> getMemberJoinRequest db user gInfo m) - where - mkEvents = \case - Just (_, Just _welcomeMsgId) -> events' - _ -> events' <> [descr] - Nothing -> pure events' - forM_ (L.nonEmpty events_) $ \events'' -> - sendGroupMemberMessages user gInfo conn events'' + -- signed items keep the author's original bytes/signature, unsigned are re-encoded; the welcome message + -- (regular groups only; never channels) is an authored element -- all batch together in order. + let fwdEls = map (uncurry encodeFwdElement) (concat fwdMsgsByItem) + welcomeEl <- welcomeElement + let (batches, dropped) = batchElements maxEncodedMsgLength (fwdEls <> maybe [] (: []) welcomeEl) + when (dropped > 0) $ toView $ CEvtChatErrors [ChatError $ CEInternalError ("sendHistory: dropped " <> show dropped <> " oversized history messages")] + forM_ batches $ \body -> + void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags False, VRValue Nothing body)] where + welcomeElement :: CM (Maybe ByteString) + welcomeElement = case descrEvent_ of + Just descr -> + withStore' (\db -> getMemberJoinRequest db user gInfo m) >>= \case + Just (_, Just _welcomeMsgId) -> pure Nothing + _ -> do + vr <- chatVersionRange + sharedMsgId <- SharedMsgId <$> drgRandomBytes 24 + case encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr, msgId = Just sharedMsgId, chatMsgEvent = descr} of + ECMEncoded body -> pure $ Just (encodeBatchElement Nothing body) + ECMLarge -> Nothing <$ toView (CEvtChatErrors [ChatError $ CEInternalError "sendHistory: welcome message too large"]) + Nothing -> pure Nothing descrEvent_ :: Maybe (ChatMsgEvent 'Json) descrEvent_ -- in channels sendHistory runs on the relay, which cannot author XMsgNew (GRRelay < GRObserver); @@ -1338,19 +1348,26 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c let GroupInfo {groupProfile = GroupProfile {description}} = gInfo fmap (\descr -> XMsgNew $ mcSimple (MCText descr)) description | otherwise = Nothing - itemForwardEvents :: CChatItem 'CTGroup -> CM [ChatMsgEvent 'Json] - itemForwardEvents cci = case cci of + itemForwardMsgs :: (CChatItem 'CTGroup, (Maybe SignedMsg, Maybe GroupMemberId)) -> CM [(GrpMsgForward, VerifiedMsg 'Json)] + itemForwardMsgs (cci, (signedMsg_, signedByGMId_)) = case cci of (CChatItem SMDRcv ci@ChatItem {content = CIRcvMsgContent mc, file}) - | not (maybe False blockedByAdmin sender_) -> do + | not (maybe False blockedByAdmin (chatItemRcvFromMember ci)) -> do fInvDescr_ <- join <$> forM file getRcvFileInvDescr - processContentItem sender_ ci mc fInvDescr_ + -- channel items carry no from-member; a signed one falls back to the stored author (verified attribution) + member_ <- maybe (resolveAuthor signedByGMId_) (pure . Just) (chatItemRcvFromMember ci) + processContentItem member_ ci mc fInvDescr_ signedMsg_ | otherwise -> pure [] - where sender_ = chatItemRcvFromMember ci - (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file}) -> do + (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file, meta = CIMeta {showGroupAsSender}}) -> do fInvDescr_ <- join <$> forM file getSndFileInvDescr - processContentItem (Just membership) ci mc fInvDescr_ + let member_ = if showGroupAsSender && isNothing signedMsg_ then Nothing else Just membership + processContentItem member_ ci mc fInvDescr_ signedMsg_ _ -> pure [] where + resolveAuthor :: Maybe GroupMemberId -> CM (Maybe GroupMember) + resolveAuthor Nothing = pure Nothing + resolveAuthor (Just gmId) = do + cxt <- chatStoreCxt + eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupMemberById db cxt user gmId) getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText)) getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do expired <- fileExpired @@ -1381,33 +1398,39 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c fInv = xftpFileInvitation fileName fileSize fInvDescr in Just (fInv, fileDescrText) | otherwise = Nothing - processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] - processContentItem sender_ ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = + processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> Maybe SignedMsg -> CM [(GrpMsgForward, VerifiedMsg 'Json)] + processContentItem member_ ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ signedMsg_ = if isNothing fInvDescr_ && not (msgContentHasText mc) then pure [] else do - let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta + let CIMeta {itemTs, itemSharedMsgId, itemTimed, showGroupAsSender} = meta quotedItemId_ = quoteItemId =<< quotedItem fInv_ = fst <$> fInvDescr_ (mc', _, mentions') = updatedMentionNames mc formattedText mentions mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions' - asGroup = isNothing sender_ - -- TODO [knocking] send history to other scopes too? - (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing asGroup mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False - -- for channel messages default chat version range to membership range - let senderVRange = maybe (memberChatVRange' membership) memberChatVRange' sender_ - xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} + -- for channel messages default chat version range to membership range + senderVRange = maybe (memberChatVRange' membership) memberChatVRange' member_ + -- member_ is Nothing only for as-group unsigned items -> FwdChannel; otherwise attribute to the member + -- (the author for signed as-group items), so the recipient can reconstruct the binding and verify + fwdSender = maybe FwdChannel (\am -> FwdMember (memberId' am) (memberShortenedName am)) member_ + fwd = GrpMsgForward {fwdSender, fwdBrokerTs = itemTs} + -- signed items forward the author's original bytes so the signature stays valid; unsigned re-encode current content + contentVM <- case signedMsg_ of + Just sm@SignedMsg {signedBody} + | Right chatMsg <- (J.eitherDecodeStrict' signedBody :: Either String (ChatMessage 'Json)) -> + pure $ VMSigned MSSVerified sm chatMsg + _ -> do + -- TODO [knocking] send history to other scopes too? + (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing showGroupAsSender mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False + pure $ VMUnsigned ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of (Just fileDescrText, Just msgId) -> do partSize <- asks $ xftpDescrPartSize . config let parts = splitFileDescr partSize fileDescrText pure . L.toList $ L.map (XMsgFileDescr msgId) parts _ -> pure [] - let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents - fwdSender = maybe FwdChannel (\s -> FwdMember (memberId' s) (memberShortenedName s)) sender_ - fwd = GrpMsgForward {fwdSender, fwdBrokerTs = itemTs} - msgForwardEvents = map (XGrpMsgForward fwd) (xMsgNewChatMsg : fileDescrChatMsgs) - pure msgForwardEvents + let fileDescrVMs = map (VMUnsigned . ChatMessage senderVRange Nothing) fileDescrEvents + pure $ map ((,) fwd) (contentVM : fileDescrVMs) memberShortenedName :: GroupMember -> ContactName memberShortenedName GroupMember {memberProfile = LocalProfile {displayName}} @@ -2722,7 +2745,7 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do let hasLink_ = ciContentHasLink content (snd itemTexts) ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (toMsgVerified False (MSSVerified <$ signedMsg_)) createdAt + let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (toMsgVerified (signMessagesRequired cd) (MSSVerified <$ signedMsg_)) createdAt Right <$> case cd of CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci @@ -3108,7 +3131,7 @@ createLocalChatItems user cd itemsData createdAt = do createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) createItem db (content, ciFile, itemForwarded, ts@(_, ft_)) = do let hasLink_ = ciContentHasLink content ft_ - ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing createdAt + ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing Nothing Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure $ mkChatItem_ cd False ciId content ts ciFile Nothing Nothing itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing createdAt diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 5b6b85acd1..056c9fac8d 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2238,7 +2238,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupMsgToView cInfo ci' {reactions} groupMessageUpdate :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> Maybe Bool -> CM (Maybe DeliveryTaskContext) - groupMessageUpdate gInfo@GroupInfo {groupId} m_ sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId, msgSigned} brokerTs ttl_ live_ asGroup_ + groupMessageUpdate gInfo@GroupInfo {groupId} m_ sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId, msgSigned, signedMsg_, signedByGMId_} brokerTs ttl_ live_ asGroup_ | Just m <- m_, prohibitedSimplexLinks gInfo m mc ft_ = messageWarning ("x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks) $> Nothing | otherwise = do @@ -2320,6 +2320,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = let edited = itemLive /= Just True ciMentions <- getRcvCIMentions db user gInfo ft_ mentions ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId + updateChatItemSignedMsg db (chatItemId' ci) signedMsg_ signedByGMId_ updateGroupCIMentions db gInfo ci' ciMentions toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci') startUpdatedTimedItemThread user (ChatRef CTGroup groupId $ toChatScope <$> scopeInfo) ci ci' diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index da9fec9053..b4709fb5c5 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -421,6 +421,7 @@ signMessagesRequired :: ChatDirection c d -> Bool signMessagesRequired = \case CDChannelRcv g _ -> groupFeatureAllowed SGFSignMessages g CDGroupRcv g _ _ -> groupFeatureAllowed SGFSignMessages g + CDGroupSnd g _ -> groupFeatureAllowed SGFSignMessages g _ -> False contactChatDeleted :: ChatDirection c d -> Bool @@ -1178,6 +1179,8 @@ data RcvMessage = RcvMessage chatMsgEvent :: AChatMsgEvent, sharedMsgId_ :: Maybe SharedMsgId, msgSigned :: Maybe MsgSigStatus, + signedMsg_ :: Maybe SignedMsg, + signedByGMId_ :: Maybe GroupMemberId, forwardedByMember :: Maybe GroupMemberId } diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index 81861aad74..9c0ed521c7 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -13,6 +13,7 @@ module Simplex.Chat.Messages.Batch encodeBinaryBatch, batchMessages, batchDeliveryTasks1, + batchElements, batchProfilesWithBody, batchProfiles, maxBatchElementSize, @@ -103,6 +104,23 @@ batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) body = if null accepted then Nothing else Just encoded in (body, reverse accepted, reverse large) +-- | Pack pre-encoded elements into binary batches within maxLen, preserving order. +-- Elements may mix forward ('encodeFwdElement') and authored ('encodeBatchElement') +-- forms; the receiver parses each by prefix. Also returns the count dropped as too large. +batchElements :: Int -> [ByteString] -> ([ByteString], Int) +batchElements maxLen = finish . foldl' addToBatch ([], [], 0, 0, 0) + where + addToBatch (batches, elems, len, n, dropped) el + | elLen + 4 > maxLen = (batches, elems, len, n, dropped + 1) + | len + elLen + (n + 1) * 2 + 2 <= maxLen = (batches, el : elems, len + elLen, n + 1, dropped) + | otherwise = (closeBatch elems : batches, [el], elLen, 1, dropped) + where + elLen = B.length el + closeBatch elems = encodeBinaryBatch (reverse elems) + finish (batches, elems, _, n, dropped) + | n == 0 = (reverse batches, dropped) + | otherwise = (reverse (closeBatch elems : batches), dropped) + -- | Encode a batch element for relay groups: >[/]. encodeFwdElement :: GrpMsgForward -> VerifiedMsg 'Json -> ByteString encodeFwdElement fwd verifiedMsg = ">" <> smpEncode fwd <> encodeBatchElement signedMsg_ msgBody diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 1cf350a79b..5d6cc8fd8b 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -61,6 +61,7 @@ module Simplex.Chat.Store.Messages markDirectChatItemDeleted, updateGroupChatItemStatus, updateGroupChatItem, + updateChatItemSignedMsg, createGroupCIMentions, updateGroupCIMentions, deleteGroupChatItem, @@ -338,7 +339,7 @@ createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, verifiedMsg, b ((MDRcv, toCMEventTag chatMsgEvent, DB.Binary msgBody, (\SignedMsg {chatBinding} -> chatBinding) <$> signedMsg_, DB.Binary . smpEncode . signatures <$> signedMsg_, brokerTs, currentTs, currentTs, connId_, groupId_) :. (sharedMsgId_, authorMember, forwardedByMember)) msgId <- insertedRowId db - pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgSigned, forwardedByMember} + pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgSigned, signedMsg_, signedByGMId_ = signedMsg_ *> authorMember, forwardedByMember} (msgSigned, signedMsg_, msgBody) = verifiedMsgParts verifiedMsg updateSndMsgDeliveryStatus :: DB.Connection -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> IO () @@ -545,7 +546,7 @@ setSupportChatMemberAttention db cxt user g m memberAttention = do createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> ShowGroupAsSender -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, sharedMsgId, signedMsg_} ciContent quotedItem itemForwarded timed live hasLink createdAt = - createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing (toMsgVerified False (MSSVerified <$ signedMsg_)) createdAt + createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing (toMsgVerified (signMessagesRequired chatDirection) (MSSVerified <$ signedMsg_)) signedMsg_ Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -560,9 +561,9 @@ createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, CIQGroupRcv Nothing -> (Just False, Nothing) createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, signedMsg_, signedByGMId_, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do let showAsGroup = case chatDirection of CDChannelRcv {} -> True; _ -> False - ciId <- createNewChatItem_ db user chatDirection showAsGroup (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember (toMsgVerified (signMessagesRequired chatDirection) msgSigned) createdAt + ciId <- createNewChatItem_ db user chatDirection showAsGroup (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember (toMsgVerified (signMessagesRequired chatDirection) msgSigned) signedMsg_ signedByGMId_ createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem, itemForwarded) where @@ -583,13 +584,13 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgS createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> Maybe MsgVerified -> UTCTime -> UTCTime -> IO ChatItemId createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink msgVerified itemTs = - createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing msgVerified + createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing msgVerified Nothing Nothing where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember msgVerified createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> Maybe GroupMemberId -> Maybe MsgVerified -> Maybe SignedMsg -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember msgVerified signedMsg_ signedByGMId_ createdAt = do DB.execute db [sql| @@ -598,20 +599,26 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, item_viewed, show_group_as_sender, msg_signed, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, item_viewed, show_group_as_sender, msg_signed, item_msg_body, item_chat_binding, item_signatures, item_signed_by_group_member_id, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. groupScopeRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe MsgVerified) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, mcTag_, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> justTrue live, BI userMention, BI hasLink, BI itemViewed, BI showGroupAsSender, msgVerified) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId, BoolInt) :. (UTCTime, UTCTime, Maybe BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe MsgVerified) :. (Maybe (DB.Binary ByteString), Maybe ChatBinding, Maybe (DB.Binary ByteString), Maybe GroupMemberId) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, mcTag_, sharedMsgId, forwardedByMember, BI includeInHistory) :. (createdAt, createdAt, BI <$> justTrue live, BI userMention, BI hasLink, BI itemViewed, BI showGroupAsSender, msgVerified) :. signedRow :. ciTimedRow timed + -- keep the signature and its author on history items so sendHistory can re-forward them signed and attributed + signedRow :: (Maybe (DB.Binary ByteString), Maybe ChatBinding, Maybe (DB.Binary ByteString), Maybe GroupMemberId) + signedRow = case signedMsg_ of + Just SignedMsg {chatBinding, signatures, signedBody} | includeInHistory -> + (Just (DB.Binary signedBody), Just chatBinding, Just (DB.Binary (smpEncode signatures)), signedByGMId_) + _ -> (Nothing, Nothing, Nothing, Nothing) quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) idsRow :: (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId, Maybe NoteFolderId) idsRow = case chatDirection of @@ -2753,6 +2760,19 @@ updateGroupChatItem db user groupId ci newContent edited live msgId_ = do liftIO $ updateGroupChatItem_ db user groupId ci' msgId_ pure ci' +-- overwrite the stored signed bytes and author when a signed edit is applied, so sendHistory forwards the latest signed content +updateChatItemSignedMsg :: DB.Connection -> ChatItemId -> Maybe SignedMsg -> Maybe GroupMemberId -> IO () +updateChatItemSignedMsg db itemId signedMsg_ signedByGMId_ = + DB.execute + db + "UPDATE chat_items SET item_msg_body = ?, item_chat_binding = ?, item_signatures = ?, item_signed_by_group_member_id = ? WHERE chat_item_id = ? AND include_in_history = 1" + (body_, cb_, sigs_, author_, itemId) + where + (body_, cb_, sigs_, author_) = case signedMsg_ of + Just SignedMsg {chatBinding, signatures, signedBody} -> + (Just (DB.Binary signedBody), Just chatBinding, Just (DB.Binary (smpEncode signatures)), signedByGMId_) + Nothing -> (Nothing, Nothing, Nothing, Nothing) + -- this function assumes that the group item with correct chat direction already exists, -- it should be checked before calling it updateGroupChatItem_ :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> Maybe MessageId -> IO () @@ -3692,18 +3712,21 @@ getGroupSndStatusCounts db itemId = |] (Only itemId) -getGroupHistoryItems :: DB.Connection -> User -> GroupInfo -> GroupMember -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] +getGroupHistoryItems :: DB.Connection -> User -> GroupInfo -> GroupMember -> Int -> IO [Either StoreError (CChatItem 'CTGroup, (Maybe SignedMsg, Maybe GroupMemberId))] getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do - ciIds <- getLastItemIds_ - reverse <$> mapM (runExceptT . getGroupCIWithReactions db user g) ciIds + items <- getLastItems_ + reverse <$> mapM loadItem items where - getLastItemIds_ :: IO [ChatItemId] - getLastItemIds_ = - map fromOnly + -- forward-info (stored signature + author, both NULL for unsigned/regular items) rides along with the item id, + -- so signed items can be re-served as FwdMember (attributed, verifiable) rather than FwdChannel + loadItem (ciId, fwdInfo) = runExceptT $ (,fwdInfo) <$> getGroupCIWithReactions db user g ciId + getLastItems_ :: IO [(ChatItemId, (Maybe SignedMsg, Maybe GroupMemberId))] + getLastItems_ = + map toItem <$> DB.query db [sql| - SELECT i.chat_item_id + SELECT i.chat_item_id, i.item_chat_binding, i.item_signatures, i.item_msg_body, i.item_signed_by_group_member_id FROM chat_items i LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? WHERE s.group_snd_item_status_id IS NULL @@ -3714,6 +3737,9 @@ getGroupHistoryItems db user@User {userId} g@GroupInfo {groupId} m count = do LIMIT ? |] (groupMemberId' m, userId, groupId, count) + toItem :: (ChatItemId, Maybe ChatBinding, Maybe ByteString, Maybe ByteString, Maybe GroupMemberId) -> (ChatItemId, (Maybe SignedMsg, Maybe GroupMemberId)) + toItem (ciId, cb_, sigs_, body_, signedByGMId_) = + (ciId, (SignedMsg <$> cb_ <*> (sigs_ >>= eitherToMaybe . smpDecode) <*> body_, signedByGMId_)) getGroupWebPreviewItems :: DB.Connection -> User -> GroupInfo -> Int -> IO [Either StoreError (CChatItem 'CTGroup)] getGroupWebPreviewItems db user@User {userId} g@GroupInfo {groupId} count = do diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 95fa4e1095..58e2d9ea45 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -43,6 +43,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260629_roster_catchup import Simplex.Chat.Store.Postgres.Migrations.M20260707_file_digest import Simplex.Chat.Store.Postgres.Migrations.M20260714_member_security_code import Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description +import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -85,7 +86,8 @@ schemaMigrations = ("20260629_roster_catchup", m20260629_roster_catchup, Just down_m20260629_roster_catchup), ("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest), ("20260714_member_security_code", m20260714_member_security_code, Just down_m20260714_member_security_code), - ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description) + ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description), + ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260716_signed_history.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260716_signed_history.hs new file mode 100644 index 0000000000..e7578d9429 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260716_signed_history.hs @@ -0,0 +1,29 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260716_signed_history :: Text +m20260716_signed_history = + [r| +ALTER TABLE chat_items ADD COLUMN item_msg_body BYTEA; +ALTER TABLE chat_items ADD COLUMN item_chat_binding TEXT; +ALTER TABLE chat_items ADD COLUMN item_signatures BYTEA; +ALTER TABLE chat_items ADD COLUMN item_signed_by_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL; + +CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items(item_signed_by_group_member_id); +|] + +down_m20260716_signed_history :: Text +down_m20260716_signed_history = + [r| +DROP INDEX idx_chat_items_item_signed_by_group_member_id; + +ALTER TABLE chat_items DROP COLUMN item_msg_body; +ALTER TABLE chat_items DROP COLUMN item_chat_binding; +ALTER TABLE chat_items DROP COLUMN item_signatures; +ALTER TABLE chat_items DROP COLUMN item_signed_by_group_member_id; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 21e77fd4f6..9489d1bcd7 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -345,7 +345,11 @@ CREATE TABLE test_chat_schema.chat_items ( show_group_as_sender smallint DEFAULT 0 NOT NULL, has_link smallint DEFAULT 0 NOT NULL, msg_signed text, - item_viewed smallint DEFAULT 0 NOT NULL + item_viewed smallint DEFAULT 0 NOT NULL, + item_msg_body bytea, + item_chat_binding text, + item_signatures bytea, + item_signed_by_group_member_id bigint ); @@ -989,7 +993,7 @@ CREATE TABLE test_chat_schema.groups ( public_member_count bigint, relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, - relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, + relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL, relay_inactive_at timestamp with time zone, relay_sent_web_domain text, roster_version bigint, @@ -2081,6 +2085,10 @@ CREATE INDEX idx_chat_items_item_deleted_by_group_member_id ON test_chat_schema. +CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON test_chat_schema.chat_items USING btree (item_signed_by_group_member_id); + + + CREATE INDEX idx_chat_items_item_status ON test_chat_schema.chat_items USING btree (item_status); @@ -2766,6 +2774,11 @@ ALTER TABLE ONLY test_chat_schema.chat_items +ALTER TABLE ONLY test_chat_schema.chat_items + ADD CONSTRAINT chat_items_item_signed_by_group_member_id_fkey FOREIGN KEY (item_signed_by_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE SET NULL; + + + ALTER TABLE ONLY test_chat_schema.chat_items ADD CONSTRAINT chat_items_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 998fbbcd1b..f9a27f93af 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -166,6 +166,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260629_roster_catchup import Simplex.Chat.Store.SQLite.Migrations.M20260707_file_digest import Simplex.Chat.Store.SQLite.Migrations.M20260714_member_security_code import Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description +import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -331,7 +332,8 @@ schemaMigrations = ("20260629_roster_catchup", m20260629_roster_catchup, Just down_m20260629_roster_catchup), ("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest), ("20260714_member_security_code", m20260714_member_security_code, Just down_m20260714_member_security_code), - ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description) + ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description), + ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260716_signed_history.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260716_signed_history.hs new file mode 100644 index 0000000000..3b4e81f10f --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260716_signed_history.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260716_signed_history :: Query +m20260716_signed_history = + [sql| +ALTER TABLE chat_items ADD COLUMN item_msg_body BLOB; +ALTER TABLE chat_items ADD COLUMN item_chat_binding TEXT; +ALTER TABLE chat_items ADD COLUMN item_signatures BLOB; +ALTER TABLE chat_items ADD COLUMN item_signed_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL; + +CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items(item_signed_by_group_member_id); +|] + +down_m20260716_signed_history :: Query +down_m20260716_signed_history = + [sql| +DROP INDEX idx_chat_items_item_signed_by_group_member_id; + +ALTER TABLE chat_items DROP COLUMN item_msg_body; +ALTER TABLE chat_items DROP COLUMN item_chat_binding; +ALTER TABLE chat_items DROP COLUMN item_signatures; +ALTER TABLE chat_items DROP COLUMN item_signed_by_group_member_id; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 719fbf9914..18af438621 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -41,6 +41,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -94,6 +95,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -299,6 +301,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -334,6 +337,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -369,6 +373,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -591,6 +596,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -625,6 +631,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -660,6 +667,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -695,6 +703,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -1048,21 +1057,6 @@ Plan: SCAN groups USE TEMP B-TREE FOR ORDER BY -Query: - SELECT i.chat_item_id - FROM chat_items i - LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? - WHERE s.group_snd_item_status_id IS NULL - AND i.user_id = ? AND i.group_id = ? - AND i.include_in_history = 1 - AND i.item_deleted = 0 - ORDER BY i.item_ts DESC, i.chat_item_id DESC - LIMIT ? - -Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?) -SEARCH s USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) LEFT-JOIN - Query: SELECT i.chat_item_id, -- GroupMember @@ -1097,6 +1091,21 @@ Plan: SEARCH f USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT i.chat_item_id, i.item_chat_binding, i.item_signatures, i.item_msg_body, i.item_signed_by_group_member_id + FROM chat_items i + LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? + WHERE s.group_snd_item_status_id IS NULL + AND i.user_id = ? AND i.group_id = ? + AND i.include_in_history = 1 + AND i.item_deleted = 0 + ORDER BY i.item_ts DESC, i.chat_item_id DESC + LIMIT ? + +Plan: +SEARCH i USING INDEX idx_chat_items_groups_history (user_id=? AND group_id=? AND include_in_history=? AND item_deleted=?) +SEARCH s USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) LEFT-JOIN + Query: SELECT ldn_suffix FROM display_names WHERE user_id = ? AND ldn_base = ? @@ -1244,6 +1253,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -1280,6 +1290,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -1913,6 +1924,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -1948,6 +1960,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -4771,12 +4784,12 @@ Query: user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, group_scope_tag, group_scope_group_member_id, -- meta item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, - forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, item_viewed, show_group_as_sender, msg_signed, timed_ttl, timed_delete_at, + forwarded_by_group_member_id, include_in_history, created_at, updated_at, item_live, user_mention, has_link, item_viewed, show_group_as_sender, msg_signed, item_msg_body, item_chat_binding, item_signatures, item_signed_by_group_member_id, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -6709,6 +6722,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -6739,6 +6753,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -6769,6 +6784,7 @@ SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_m SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_signed_by_group_member_id (item_signed_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) @@ -7501,6 +7517,10 @@ Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE chat_items SET item_msg_body = ?, item_chat_binding = ?, item_signatures = ?, item_signed_by_group_member_id = ? WHERE chat_item_id = ? AND include_in_history = 1 +Plan: +SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE chat_items SET item_status = ?, item_viewed = 1, updated_at = ? WHERE user_id = ? AND item_status = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_user_id_item_status (user_id=? AND item_status=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 94eca2e532..1c5a5bb445 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -508,7 +508,11 @@ CREATE TABLE chat_items( show_group_as_sender INTEGER NOT NULL DEFAULT 0, has_link INTEGER NOT NULL DEFAULT 0, msg_signed TEXT, - item_viewed INTEGER NOT NULL DEFAULT 0 + item_viewed INTEGER NOT NULL DEFAULT 0, + item_msg_body BLOB, + item_chat_binding TEXT, + item_signatures BLOB, + item_signed_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL ) STRICT; CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -1368,6 +1372,9 @@ CREATE INDEX idx_files_group_id_shared_msg_id ON files( shared_msg_id ); CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id); +CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items( + item_signed_by_group_member_id +); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 8420e8e58a..767728e400 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -36,7 +36,7 @@ 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 (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -343,6 +343,8 @@ chatGroupTests = do it "should always sign moderation delete" testChannelModerationDeleteSign it "should verify signed file digest" testChannelSignedFile it "should warn on missing signature when signing is required" testChannelSignMessagesRequired + it "should preserve signatures in history for catch-up subscribers" testChannelSignedHistory + it "should forward unsigned channel history for catch-up subscribers" testChannelUnsignedHistory testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -12383,13 +12385,13 @@ testChannelSignMessagesRequired ps = alice #$> ("/_get chat #1 count=100 search=signed by owner", chat, [(1, "signed by owner (signed)")]) dan #$> ("/_get chat #1 count=100 search=signed by owner", chat, [(0, "signed by owner (signed)")]) - -- owner posts as the channel, unsigned: recipients warn (held in db), sender does not + -- owner posts as the channel without explicit sign: auto-signed because signing is required alice ##> "/_send #1(as_group=on) text plain from owner" - alice <# "#team plain from owner" - bob <# "#team> plain from owner (signature missing)" - [cath, dan, eve] *<# "#team> plain from owner (signature missing) [>>]" - alice #$> ("/_get chat #1 count=100 search=plain from owner", chat, [(1, "plain from owner")]) - dan #$> ("/_get chat #1 count=100 search=plain from owner", chat, [(0, "plain from owner (signature missing)")]) + alice <# "#team plain from owner (signed)" + bob <# "#team> plain from owner (signed)" + [cath, dan, eve] *<# "#team> plain from owner (signed) [>>]" + alice #$> ("/_get chat #1 count=100 search=plain from owner", chat, [(1, "plain from owner (signed)")]) + dan #$> ("/_get chat #1 count=100 search=plain from owner", chat, [(0, "plain from owner (signed)")]) -- promoted subscriber posts signed: verified for recipients (members are required to sign too) cath ##> "/_send #1 sign=on text signed by member" @@ -12409,16 +12411,147 @@ testChannelSignMessagesRequired ps = cath #$> ("/_get chat #1 count=100 search=signed by member", chat, [(1, "signed by member (signed)")]) dan #$> ("/_get chat #1 count=100 search=signed by member", chat, [(0, "signed by member (signed)")]) - -- promoted subscriber posts unsigned: recipients warn (held in db), sender does not - cath #> "#team plain from member" - bob <# "#team cath> plain from member (signature missing)" + -- promoted subscriber posts without explicit sign: auto-signed + cath ##> "/_send #1 text plain from member" + cath <# "#team plain from member (signed)" + bob <# "#team cath> plain from member (signed)" concurrentlyN_ - [ alice <# "#team cath> plain from member (signature missing) [>>]", - dan <# "#team cath> plain from member (signature missing) [>>]", - eve <# "#team cath> plain from member (signature missing) [>>]" + [ alice <# "#team cath> plain from member (signed) [>>]", + dan <# "#team cath> plain from member (signed) [>>]", + eve <# "#team cath> plain from member (signed) [>>]" ] - cath #$> ("/_get chat #1 count=100 search=plain from member", chat, [(1, "plain from member")]) - dan #$> ("/_get chat #1 count=100 search=plain from member", chat, [(0, "plain from member (signature missing)")]) + cath #$> ("/_get chat #1 count=100 search=plain from member", chat, [(1, "plain from member (signed)")]) + dan #$> ("/_get chat #1 count=100 search=plain from member", chat, [(0, "plain from member (signed)")]) + + -- a malicious relay forwards an unsigned channel message; the recipient holds it with a missing-signature warning + danConnId <- relayConnIdToMember bob "dan" + forgeTs <- getCurrentTime + let ChatController {smpAgent = bobAgent} = chatController bob + forgedMsg = ChatMessage chatInitialVRange (Just (SharedMsgId "forged-unsigned-01")) (XMsgNew $ mcSimple (MCText "forged unsigned")) + forgedBody = encodeBinaryBatch [encodeFwdElement (GrpMsgForward FwdChannel forgeTs) (VMUnsigned forgedMsg)] + sentForged <- runExceptT $ sendMessages bobAgent [(danConnId, PQEncOff, MsgFlags False, vrValue forgedBody)] + either (fail . show) (const $ pure ()) sentForged + dan <# "#team> forged unsigned (signature missing) [>>]" + dan #$> ("/_get chat #1 count=100 search=forged unsigned", chat, [(0, "forged unsigned (signature missing)")]) + +testChannelSignedHistory :: HasCallStack => TestParams -> IO () +testChannelSignedHistory ps = + testChat4 aliceProfile bobProfile cathProfile danProfile test ps + where + test alice bob cath dan = withRelay ps $ \relay -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay + memberJoinChannel "team" [relay] [alice] shortLink fullLink bob + memberJoinChannel "team" [relay] [alice] shortLink fullLink cath + -- require signatures + alice ##> "/set signatures #team on" + alice <## "updated group preferences:" + alice <## "Sign messages: on" + concurrentlyN_ + [ do + relay <## "alice updated group #team: (signed)" + relay <## "updated group preferences:" + relay <## "Sign messages: on", + do + bob <## "alice updated group #team: (signed)" + bob <## "updated group preferences:" + bob <## "Sign messages: on", + do + cath <## "alice updated group #team: (signed)" + cath <## "updated group preferences:" + cath <## "Sign messages: on" + ] + + -- signed post that is edited: history must carry the current, signed content + alice ##> "/_send #1(as_group=on) text history one" + alice <# "#team history one (signed)" + relay <# "#team> history one (signed)" + [bob, cath] *<# "#team> history one (signed) [>>]" + editId <- lastItemId alice + alice ##> ("/_update item #1 " <> editId <> " text history one edited") + alice <# "#team [edited] history one edited (signed)" + relay <# "#team> [edited] history one edited (signed)" + [bob, cath] *<# "#team> [edited] history one edited (signed)" + + -- signed post that is deleted from history: excluded from history + alice ##> "/_send #1(as_group=on) text history two" + alice <# "#team history two (signed)" + relay <# "#team> history two (signed)" + [bob, cath] *<# "#team> history two (signed) [>>]" + delId <- lastItemId alice + alice #$> ("/_delete item #1 " <> delId <> " history", id, "message marked deleted") + relay <# "#team> [marked deleted] history two (signed)" + + -- promoted member posts a signed message (from-member): history attributes it to the member, still verified + promoteChannelMember "team" alice relay cath [bob] + cath ##> "/_send #1 text from cath member" + cath <# "#team from cath member (signed)" + relay <# "#team cath> from cath member (signed)" + concurrentlyN_ + [ alice <# "#team cath> from cath member (signed) [>>]", + do + bob <### [EndsWith "updated to cath"] + bob <## "#team: relay introduced cath (Catherine) in the channel" + bob <# "#team cath> from cath member (signed) [>>]" + ] + + -- catch-up subscriber joins late: as-group edited content + signed member post (author unknown to dan, shown by id hash), deleted excluded + memberJoinChannel "team" [relay] [alice] shortLink fullLink dan + dan <# "#team> [edited] history one edited (signed)" + dan .<## "> from cath member (signed) [>>]" + dan #$> ("/_get chat #1 count=100 search=history one edited", chat, [(0, "history one edited (signed)")]) + dan #$> ("/_get chat #1 count=100 search=from cath member", chat, [(0, "from cath member (signed)")]) + dan #$> ("/_get chat #1 count=100 search=history two", chat, []) + +testChannelUnsignedHistory :: HasCallStack => TestParams -> IO () +testChannelUnsignedHistory ps = + testChat4 aliceProfile bobProfile cathProfile danProfile test ps + where + test alice bob cath dan = withRelay ps $ \relay -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay + memberJoinChannel "team" [relay] [alice] shortLink fullLink bob + memberJoinChannel "team" [relay] [alice] shortLink fullLink cath + + -- unsigned as-group post that is edited: history carries the current content + alice ##> "/_send #1(as_group=on) text history one" + alice <# "#team history one" + relay <# "#team> history one" + [bob, cath] *<# "#team> history one [>>]" + editId <- lastItemId alice + alice ##> ("/_update item #1 " <> editId <> " text history one edited") + alice <# "#team [edited] history one edited" + relay <# "#team> [edited] history one edited" + [bob, cath] *<# "#team> [edited] history one edited" + + -- unsigned as-group post deleted from history: excluded + alice ##> "/_send #1(as_group=on) text history two" + alice <# "#team history two" + relay <# "#team> history two" + [bob, cath] *<# "#team> history two [>>]" + delId <- lastItemId alice + alice #$> ("/_delete item #1 " <> delId <> " history", id, "message marked deleted") + relay <# "#team> [marked deleted] history two" + + -- promoted member posts an unsigned message (from-member): history attributes it to the member + promoteChannelMember "team" alice relay cath [bob] + cath ##> "/_send #1 text from cath member" + cath <# "#team from cath member" + relay <# "#team cath> from cath member" + concurrentlyN_ + [ alice <# "#team cath> from cath member [>>]", + do + bob <### [EndsWith "updated to cath"] + bob <## "#team: relay introduced cath (Catherine) in the channel" + bob <# "#team cath> from cath member [>>]" + ] + + -- catch-up subscriber joins late: unsigned edits re-encode as current content (no [edited] marker, unlike signed); + -- member post shown by id hash (author unknown to dan); deleted excluded + memberJoinChannel "team" [relay] [alice] shortLink fullLink dan + dan <# "#team> history one edited [>>]" + dan .<## "> from cath member [>>]" + dan #$> ("/_get chat #1 count=100 search=history one edited", chat, [(0, "history one edited")]) + dan #$> ("/_get chat #1 count=100 search=from cath member", chat, [(0, "from cath member")]) + dan #$> ("/_get chat #1 count=100 search=history two", chat, []) testChannelAsGroupSpoof :: HasCallStack => TestParams -> IO () testChannelAsGroupSpoof ps =