mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-03 04:42:21 +00:00
wip
This commit is contained in:
@@ -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 ()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+178
-1
@@ -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 $
|
||||
|
||||
Reference in New Issue
Block a user