diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 22910d8bb9..984e4fa63d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -736,7 +736,7 @@ processChatCommand cxt nm = \case -- TODO [knocking] check chat item scope? cci <- withFastStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of - CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender}, content = ciContent} -> do + CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender, msgSigned}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope @@ -748,7 +748,8 @@ processChatCommand cxt nm = \case let msgScope = toMsgScope gInfo <$> chatScopeInfo mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope (Just showGroupAsSender) - SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients False event + reuseSign = isJust msgSigned + SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients reuseSign event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -5108,7 +5109,7 @@ chatCommandP = "/_get content types " *> (APIGetChatContentTypes <$> chatRefP), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> textP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), - "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> pure False <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), + "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> signMessagesP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create tag " *> (APICreateChatTag <$> jsonP), "/_tags " *> (APISetChatTags <$> chatRefP <*> optional _strP), "/_delete tag " *> (APIDeleteChatTag <$> A.decimal), @@ -5482,6 +5483,7 @@ chatCommandP = pure [composedMessage Nothing text] updatedMessagesTextP = (`UpdatedMessage` []) <$> mcTextP liveMessageP = " live=" *> onOffP <|> pure False + signMessagesP = " sign=" *> onOffP <|> pure False sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing receiptSettings = do enable <- onOffP diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 8ac485757e..895d3d8160 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2223,7 +2223,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} brokerTs ttl_ live_ asGroup_ + groupMessageUpdate gInfo@GroupInfo {groupId} m_ sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId, msgSigned} 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 @@ -2275,15 +2275,22 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId (cci,) <$> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) case cci of - CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} - | isSender m' -> updateCI False ci scopeInfo oldMC itemLive (Just $ memberId' m') + CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive, msgSigned = itemSigned}, content = CIRcvMsgContent oldMC} + | isSender m' -> requireSignedMutation (CDGroupRcv gInfo scopeInfo m') itemSigned $ updateCI False ci scopeInfo oldMC itemLive (Just $ memberId' m') | otherwise -> messageError "x.msg.update: group member attempted to update a message of another member" $> Nothing - CChatItem SMDRcv ci@ChatItem {chatDir = CIChannelRcv, meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} - | maybe True (\m -> memberRole' m == GROwner) m_ -> updateCI True ci scopeInfo oldMC itemLive Nothing + CChatItem SMDRcv ci@ChatItem {chatDir = CIChannelRcv, meta = CIMeta {itemLive, msgSigned = itemSigned}, content = CIRcvMsgContent oldMC} + | maybe True (\m -> memberRole' m == GROwner) m_ -> requireSignedMutation (CDChannelRcv gInfo scopeInfo) itemSigned $ updateCI True ci scopeInfo oldMC itemLive Nothing | otherwise -> messageError "x.msg.update: member attempted to update channel message" $> Nothing _ -> messageError "x.msg.update: invalid message update" $> Nothing where isSender m' = maybe False (\m -> sameMemberId (memberId' m) m') m_ + -- reject an unsigned XMsgUpdate of a held-signed item (edit-downgrade spoof) fail-closed, mirroring withVerifiedMsg + requireSignedMutation :: ChatDirection 'CTGroup 'MDRcv -> Maybe MsgSigStatus -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) + requireSignedMutation cd itemSigned action + | isJust itemSigned && isNothing msgSigned = do + createInternalChatItem user cd (CIRcvGroupEvent RGEMsgBadSignature) (Just brokerTs) + pure Nothing + | otherwise = action updateCI :: ShowGroupAsSender -> ChatItem 'CTGroup 'MDRcv -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe Bool -> Maybe MemberId -> CM (Maybe DeliveryTaskContext) updateCI showGroupAsSender ci scopeInfo oldMC itemLive memberId = do let changed = mc /= oldMC diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 51fe8c54aa..6f9e3032d6 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), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XMsgUpdate), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -330,6 +330,8 @@ chatGroupTests = do it "should compute sendAsGroup in CLI forward" testForwardCLISendAsGroup it "should update member message in channel" testChannelMemberMessageUpdate it "should delete member message in channel" testChannelMemberMessageDelete + it "should sign member message and reuse signature on edit" testChannelMemberMessageSign + it "should reject unsigned update of a signed item" testChannelMemberUpdateEnforcement testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -11952,6 +11954,136 @@ testChannelMemberMessageDelete ps = eve <# "#team cath> [marked deleted] hello" ] +testChannelMemberMessageSign :: HasCallStack => TestParams -> IO () +testChannelMemberMessageSign ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- member sends a signed message + cath ##> "/_send #1 sign=on text signed hello" + cath <# "#team signed hello" + bob <# "#team cath> signed hello" + concurrentlyN_ + [ alice <# "#team cath> signed hello [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> signed hello [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> signed hello [>>]" + ] + -- sender and recipient hold it signed + cath #$> ("/_get chat #1 count=1", chat, [(1, "signed hello (signed)")]) + dan #$> ("/_get chat #1 count=1", chat, [(0, "signed hello (signed)")]) + + -- editing a signed item reuses the signature + cathMsgId <- lastItemId cath + cath ##> ("/_update item #1 " <> cathMsgId <> " text signed hello edited") + cath <# "#team [edited] signed hello edited" + bob <# "#team cath> [edited] signed hello edited" + concurrentlyN_ + [ alice <# "#team cath> [edited] signed hello edited", + dan <# "#team cath> [edited] signed hello edited", + eve <# "#team cath> [edited] signed hello edited" + ] + cath #$> ("/_get chat #1 count=1", chat, [(1, "signed hello edited (signed)")]) + dan #$> ("/_get chat #1 count=1", chat, [(0, "signed hello edited (signed)")]) + + -- default send is unsigned, and holds no signature + cath #> "#team plain hello" + bob <# "#team cath> plain hello" + concurrentlyN_ + [ alice <# "#team cath> plain hello [>>]", + dan <# "#team cath> plain hello [>>]", + eve <# "#team cath> plain hello [>>]" + ] + cath #$> ("/_get chat #1 count=1", chat, [(1, "plain hello")]) + dan #$> ("/_get chat #1 count=1", chat, [(0, "plain hello")]) + +testChannelMemberUpdateEnforcement :: HasCallStack => TestParams -> IO () +testChannelMemberUpdateEnforcement ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + promoteChannelMember "team" alice bob cath [dan, eve] + + -- cath posts a signed message; dan holds it verified + cath ##> "/_send #1 sign=on text secret" + cath <# "#team secret" + bob <# "#team cath> secret" + concurrentlyN_ + [ alice <# "#team cath> secret [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> secret [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> secret [>>]" + ] + dan #$> ("/_get chat #1 count=1", chat, [(0, "secret (signed)")]) + + -- the malicious relay forges an unsigned XMsgUpdate of cath's signed item to dan + cathMemId <- memberIdByName bob "cath" + sharedId <- itemSharedMsgId cath + connId <- relayConnIdToMember bob "dan" + ts <- getCurrentTime + let ChatController {smpAgent = bobAgent} = chatController bob + chatMsg = ChatMessage chatInitialVRange Nothing (XMsgUpdate sharedId (MCText "forged") M.empty Nothing Nothing Nothing Nothing) + fwd = GrpMsgForward (FwdMember cathMemId "cath") ts + body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)] + sent <- runExceptT $ sendMessages bobAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] + either (fail . show) (const $ pure ()) sent + -- dan rejects the unsigned mutation of the held-signed item (RGEMsgBadSignature, stored not shown live), + -- and the original signed content is not overwritten + threadDelay 2000000 + dan #$> ("/_get chat #1 count=2", chat, [(0, "secret (signed)"), (0, "message rejected: bad signature")]) + + -- a legitimate signed edit by cath is accepted + cathMsgId <- lastItemId cath + cath ##> ("/_update item #1 " <> cathMsgId <> " text secret edited") + cath <# "#team [edited] secret edited" + bob <# "#team cath> [edited] secret edited" + concurrentlyN_ + [ alice <# "#team cath> [edited] secret edited", + dan <# "#team cath> [edited] secret edited", + eve <# "#team cath> [edited] secret edited" + ] + dan #$> ("/_get chat #1 count=2", chat, [(0, "secret edited (signed)"), (0, "message rejected: bad signature")]) + where + memberIdByName :: TestCC -> T.Text -> IO MemberId + memberIdByName cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_id FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only ByteString] + case rows of + (Only mid : _) -> pure (MemberId mid) + _ -> fail $ "no member " <> T.unpack name + relayConnIdToMember :: TestCC -> T.Text -> IO ByteString + relayConnIdToMember cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query + db + "SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ?" + (Only name) :: + IO [Only ByteString] + case rows of + (Only connId : _) -> pure connId + _ -> fail $ "no relay connection to member " <> T.unpack name + itemSharedMsgId :: TestCC -> IO SharedMsgId + itemSharedMsgId cc = do + rows <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT shared_msg_id FROM chat_items WHERE shared_msg_id IS NOT NULL ORDER BY chat_item_id DESC LIMIT 1" :: IO [Only ByteString] + case rows of + (Only smid : _) -> pure (SharedMsgId smid) + _ -> fail "no shared_msg_id" + testGroupLinkContentFilter :: HasCallStack => TestParams -> IO () testGroupLinkContentFilter = testChat3 aliceProfile bobProfile cathProfile $