This commit is contained in:
spaced4ndy
2026-06-29 17:35:55 +04:00
parent cb145191e3
commit 09c6244659
3 changed files with 150 additions and 9 deletions
+5 -3
View File
@@ -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
+12 -5
View File
@@ -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
+133 -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), 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 $