diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c2cd1f2ddc..8a1282b759 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -808,16 +808,14 @@ processChatCommand cxt nm = \case recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier - let msgIds = itemsMsgIds items - events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) False) msgIds - mapM_ (sendGroupMessages user gInfo Nothing False recipients False) events + let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned gInfo chatScopeInfo False) items + mapM_ (sendGroupSignedMessages user gInfo Nothing False recipients) signedEvents delGroupChatItems user gInfo chatScopeInfo items False CIDMHistory -> do unless (publicGroupEditor gInfo (membership gInfo)) $ throwChatError CEInvalidChatItemDelete recipients <- getGroupRecipients cxt user gInfo chatScopeInfo groupKnockingVersion - let msgIds = itemsMsgIds items - events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) True) msgIds - mapM_ (sendGroupMessages user gInfo Nothing False recipients False) events + let signedEvents = L.nonEmpty $ mapMaybe (delEventSigned gInfo chatScopeInfo True) items + mapM_ (sendGroupSignedMessages user gInfo Nothing False recipients) signedEvents delGroupChatItems user gInfo chatScopeInfo items False pure $ CRChatItemsDeleted user deletions True False CTLocal -> do @@ -839,6 +837,10 @@ processChatCommand cxt nm = \case SMDRcv -> False itemsMsgIds :: [CChatItem c] -> [SharedMsgId] itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId) + -- per-item self/history delete signer: sign iff the target item was held signed (preserves self-delete deniability) + delEventSigned :: GroupInfo -> Maybe GroupChatScopeInfo -> Bool -> CChatItem 'CTGroup -> Maybe (Maybe MsgSigning, ChatMsgEvent 'Json) + delEventSigned gInfo chatScopeInfo onlyHistory (CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId, msgSigned}}) = + (\msgId -> let evt = XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) onlyHistory in (groupMsgSigning (isJust msgSigned) gInfo evt, evt)) <$> itemSharedMsgId APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do (gInfo, items) <- getCommandGroupChatItems user gId itemIds -- TODO [knocking] check scope is Nothing for all items? (prohibit moderation in support chats?) @@ -3913,8 +3915,9 @@ processChatCommand cxt nm = \case assertDeletable gInfo items assertUserGroupRole gInfo GRModerator let msgMemIds = itemsMsgMemIds gInfo items - events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False) msgMemIds - mapM_ (sendGroupMessages_ user gInfo ms False) events + -- moderation deletes always sign (attributable; avoids the catch-up-moderator divergence) + signedEvents = L.nonEmpty $ map (\(msgId, memId) -> let evt = XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False in (groupMsgSigning True gInfo evt, evt)) msgMemIds + mapM_ (sendGroupSignedMessages_ gInfo ms) signedEvents delGroupChatItems user gInfo chatScopeInfo items True where assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 4119e1e09c..dd5ec391fb 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2353,10 +2353,20 @@ sendRelayCapIfNeeded user gInfo = do sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) sendGroupMessages user gInfo scope asGroup members sign events = do + sendGroupProfileUpdate user gInfo scope asGroup members + sendGroupMessages_ user gInfo members sign events + +-- per-item signer variant of sendGroupMessages (used for per-item delete signing); preserves the profile-update prelude +sendGroupSignedMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult) +sendGroupSignedMessages user gInfo scope asGroup members signedEvents = do + sendGroupProfileUpdate user gInfo scope asGroup members + sendGroupSignedMessages_ gInfo members signedEvents + +sendGroupProfileUpdate :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> CM () +sendGroupProfileUpdate user gInfo scope asGroup members = -- TODO [knocking] send current profile to pending member after approval? when shouldSendProfileUpdate $ sendProfileUpdate `catchAllErrors` eToView - sendGroupMessages_ user gInfo members sign events where User {profile = p, userMemberProfileUpdatedAt} = user GroupInfo {userMemberProfileSentAt} = gInfo diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a79fd2d247..b91d23f282 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2319,7 +2319,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) groupMessageDelete gInfo@GroupInfo {membership} m_ sharedMsgId sndMemberId_ scope_ onlyHistory rcvMsg brokerTs = findItem >>= \case - Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case (chatDir, m_) of + Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> requireSignedDelete cci $ case (chatDir, m_) of (CIGroupRcv mem, Just m@GroupMember {memberId}) -> let msgMemberId = fromMaybe memberId sndMemberId_ isAuthor = sameMemberId memberId mem @@ -2365,7 +2365,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = messageError ("x.msg.del: channel message not found, " <> tshow e) $> Nothing where isOwner = maybe True (\m -> memberRole' m == GROwner) m_ - RcvMessage {msgId} = rcvMsg + RcvMessage {msgId, msgSigned} = rcvMsg findItem = do let tryMemberLookup mId = withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo mId sharedMsgId) @@ -2395,6 +2395,19 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" $> Nothing | otherwise = a + -- reject an unsigned XMsgDel of a held-signed item (delete-censorship spoof) fail-closed, mirroring requireSignedMutation + requireSignedDelete :: CChatItem 'CTGroup -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) + requireSignedDelete cci@(CChatItem _ ChatItem {chatDir, meta = CIMeta {msgSigned = itemSigned}}) action + | isJust itemSigned && isNothing msgSigned = do + scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) + let cd :: ChatDirection 'CTGroup 'MDRcv + cd = case chatDir of + CIGroupRcv mem -> CDGroupRcv gInfo scopeInfo mem + CIChannelRcv -> CDChannelRcv gInfo scopeInfo + CIGroupSnd -> CDGroupRcv gInfo scopeInfo membership + createInternalChatItem user cd (CIRcvGroupEvent RGEMsgBadSignature) (Just brokerTs) + pure Nothing + | otherwise = action delete :: CChatItem 'CTGroup -> Bool -> Maybe GroupMember -> CM (Maybe DeliveryTaskContext) delete cci asGroup byGroupMember = do scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db cxt user gInfo (cChatItemId cci) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 84af2b48df..08bb381496 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), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) +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.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -336,6 +336,9 @@ chatGroupTests = do it "should reject unsigned update of a signed item" testChannelMemberUpdateEnforcement it "should sign as-channel post and keep it displayed as the channel" testChannelAsGroupSign it "should reject a non-owner posting as the channel" testChannelAsGroupSpoof + it "should sign self-delete of a signed item" testChannelMemberSelfDeleteSign + it "should reject unsigned delete of a signed item" testChannelMemberDeleteEnforcement + it "should always sign moderation delete" testChannelModerationDeleteSign testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -12290,6 +12293,180 @@ testChannelAsGroupSpoof ps = (Only connId : _) -> pure connId _ -> fail $ "no relay connection to member " <> T.unpack name +testChannelMemberSelfDeleteSign :: HasCallStack => TestParams -> IO () +testChannelMemberSelfDeleteSign 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; dan holds it verified + 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 [>>]" + ] + dan #$> ("/_get chat #1 count=100 search=signed hello", chat, [(0, "signed hello (signed)")]) + + -- self-delete of the signed item: signed delete, dan (holding it signed) accepts + cathMsgId <- lastItemId cath + cath #$> ("/_delete item #1 " <> cathMsgId <> " broadcast", id, "message marked deleted") + bob <# "#team cath> [marked deleted] signed hello" + concurrentlyN_ + [ alice <# "#team cath> [marked deleted] signed hello", + dan <# "#team cath> [marked deleted] signed hello", + eve <# "#team cath> [marked deleted] signed hello" + ] + + -- self-delete of an unsigned item: unsigned delete, accepted (no enforcement) + 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 [>>]" + ] + cathMsgId2 <- lastItemId cath + cath #$> ("/_delete item #1 " <> cathMsgId2 <> " broadcast", id, "message marked deleted") + bob <# "#team cath> [marked deleted] plain hello" + concurrentlyN_ + [ alice <# "#team cath> [marked deleted] plain hello", + dan <# "#team cath> [marked deleted] plain hello", + eve <# "#team cath> [marked deleted] plain hello" + ] + +testChannelMemberDeleteEnforcement :: HasCallStack => TestParams -> IO () +testChannelMemberDeleteEnforcement 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=100 search=secret", chat, [(0, "secret (signed)")]) + + -- the relay forges an unsigned XMsgDel 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 (XMsgDel sharedId Nothing Nothing False) + 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 delete of the held-signed item; item not deleted, rejection recorded + threadDelay 2000000 + dan #$> ("/_get chat #1 count=100 search=secret", chat, [(0, "secret (signed)")]) + dan #$> ("/_get chat #1 count=100 search=bad signature", chat, [(0, "message rejected: bad signature")]) + + -- a legitimate signed self-delete by cath is accepted + cathMsgId <- lastItemId cath + cath #$> ("/_delete item #1 " <> cathMsgId <> " broadcast", id, "message marked deleted") + bob <# "#team cath> [marked deleted] secret" + concurrentlyN_ + [ alice <# "#team cath> [marked deleted] secret", + dan <# "#team cath> [marked deleted] secret", + eve <# "#team cath> [marked deleted] secret" + ] + 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" + +testChannelModerationDeleteSign :: HasCallStack => TestParams -> IO () +testChannelModerationDeleteSign 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 moderated post" + cath <# "#team moderated post" + bob <# "#team cath> moderated post" + concurrentlyN_ + [ alice <# "#team cath> moderated post [>>]", + do dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> moderated post [>>]", + do eve <### [EndsWith "updated to cath"] + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> moderated post [>>]" + ] + dan #$> ("/_get chat #1 count=100 search=moderated post", chat, [(0, "moderated post (signed)")]) + + -- owner moderation-deletes cath's signed post; the always-signed delete is accepted by dan (holding it signed) + -- resolve alice's item id by text (not lastItemId) so a racing trailing event can't select the wrong item + catItemIdOnAlice <- itemIdByText alice "moderated post" + alice ##> ("/_delete member item #1 " <> catItemIdOnAlice) + alice <## "message marked deleted by you" + concurrentlyN_ + [ bob <# "#team cath> [marked deleted by alice] moderated post", + cath <# "#team cath> [marked deleted by alice] moderated post", + dan <# "#team cath> [marked deleted by alice] moderated post", + eve <# "#team cath> [marked deleted by alice] moderated post" + ] + where + itemIdByText :: TestCC -> T.Text -> IO String + itemIdByText cc t = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT chat_item_id FROM chat_items WHERE item_text LIKE '%' || ? || '%' ORDER BY chat_item_id DESC LIMIT 1" (Only t) :: IO [Only Int64] + case rows of + (Only i : _) -> pure (show i) + _ -> fail $ "no item with text " <> T.unpack t + testGroupLinkContentFilter :: HasCallStack => TestParams -> IO () testGroupLinkContentFilter = testChat3 aliceProfile bobProfile cathProfile $