This commit is contained in:
spaced4ndy
2026-07-02 19:33:38 +04:00
parent 049b41dd78
commit 786b741b0c
4 changed files with 215 additions and 12 deletions
+11 -8
View File
@@ -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 ()
+11 -1
View File
@@ -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
+15 -2
View File
@@ -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
View File
@@ -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 $