mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-28 07:34:16 +00:00
core: roster delivery via inline file
This commit is contained in:
@@ -236,6 +236,7 @@ This threat model assumes the [SimpleX network threat model](https://github.com/
|
||||
- Selectively target specific subscribers while delivering correctly to others.
|
||||
- Ignore the "message from channel" directive, revealing which owner sent a message. Detectable out-of-band.
|
||||
- Fabricate or hide subscriber connections, inflating or deflating counts. Detectable if subscribers are connected to other relays.
|
||||
- Replay a previously valid roster - the owner-signed header plus its blob - to a *new* joiner, re-introducing a member whose privileged role was later revoked (or masking a later demotion). The owner signature binds the channel entity ID and the roster version, and the header's digest binds the blob to that header, so cross-channel and cross-version substitution remain blocked; but a same-group replay to a joiner that has not yet seen a newer version is not prevented. Existing members are protected by the monotonic roster version check - they reject any roster not strictly newer than the one already applied, so the replay reaches only members with no prior roster state.
|
||||
|
||||
*cannot:*
|
||||
|
||||
|
||||
@@ -1270,6 +1270,8 @@ processChatCommand vr nm = \case
|
||||
filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo
|
||||
withGroupLock "deleteChat group" chatId $ do
|
||||
deleteCIFiles user filesInfo
|
||||
-- the roster blob file has no chat item, so it is missed by getGroupFileInfo above
|
||||
cleanupGroupRosterFile user gInfo
|
||||
(members, recipients) <- getRecipients gInfo
|
||||
let doSendDel = memberActive membership && isOwner
|
||||
msgSigned <-
|
||||
@@ -1304,6 +1306,7 @@ processChatCommand vr nm = \case
|
||||
gInfo <- withFastStore $ \db -> getGroupInfo db vr user chatId
|
||||
filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo
|
||||
deleteCIFiles user filesInfo
|
||||
cleanupGroupRosterFile user gInfo
|
||||
withFastStore' $ \db -> deleteGroupChatItemsMessages db user gInfo
|
||||
membersToDelete <- withFastStore' $ \db -> getGroupMembersForExpiration db vr user gInfo
|
||||
forM_ membersToDelete $ \m -> withFastStore' $ \db -> deleteGroupMember db user m
|
||||
|
||||
@@ -79,6 +79,7 @@ import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Util (encryptFile, shuffle)
|
||||
import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription)
|
||||
import qualified Simplex.FileTransfer.Description as FD
|
||||
import qualified Simplex.Messaging.Crypto.Lazy as LC
|
||||
import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI)
|
||||
import Simplex.FileTransfer.Types (RcvFileId, SndFileId)
|
||||
import Simplex.Messaging.Agent
|
||||
@@ -1172,15 +1173,19 @@ memberIntroEvt gInfo reMember =
|
||||
forwardGroupRoster :: User -> GroupInfo -> GroupMember -> CM ()
|
||||
forwardGroupRoster user gInfo subscriber = do
|
||||
vr <- chatVersionRange
|
||||
withStore' (\db -> getGroupRoster db gInfo) >>= \case
|
||||
Nothing -> pure ()
|
||||
Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}) ->
|
||||
forM_ (eitherToMaybe (J.eitherDecodeStrict' signedBody) :: Maybe (ChatMessage 'Json)) $ \chatMsg ->
|
||||
withStore' (\db -> (,) <$> getGroupRoster db gInfo <*> getRosterBlob db gInfo) >>= \case
|
||||
(Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}), blob_) ->
|
||||
forM_ (eitherToMaybe (J.eitherDecodeStrict' signedBody) :: Maybe (ChatMessage 'Json)) $ \chatMsg@ChatMessage {msgId} ->
|
||||
withStore' (\db -> runExceptT $ getGroupMemberById db vr user ownerGMId) >>= \case
|
||||
Right owner -> do
|
||||
let fwd = GrpMsgForward {fwdSender = FwdMember (memberId' owner) (memberShortenedName owner), fwdBrokerTs = brokerTs}
|
||||
sendFwdMemberMessage subscriber fwd (VMSigned MSSVerified sm chatMsg)
|
||||
-- re-serve the blob under the owner's original shared_msg_id (carried in the forwarded header),
|
||||
-- so the joiner keys the roster file the same way whether it arrived direct or forwarded
|
||||
forM_ ((,) <$> msgId <*> blob_) $ \(sid, blob) ->
|
||||
sendRosterBlobChunks user gInfo [subscriber] sid blob
|
||||
Left _ -> pure ()
|
||||
_ -> pure ()
|
||||
|
||||
-- Used in groups with relays to introduce moderators and above to a new member,
|
||||
-- and to announce the new member to moderators and above.
|
||||
@@ -1238,9 +1243,10 @@ isRosterRole :: GroupMemberRole -> Bool
|
||||
isRosterRole r = r == GRModerator || r == GRAdmin
|
||||
|
||||
-- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first.
|
||||
validateGroupRoster :: GroupRoster -> GroupRoster
|
||||
validateGroupRoster GroupRoster {version = ver, roster = entries} =
|
||||
GroupRoster {version = ver, roster = dedup [] $ filter (\RosterMember {role} -> isRosterRole role) entries}
|
||||
-- Runs on the parsed roster blob.
|
||||
validateGroupRoster :: [RosterMember] -> [RosterMember]
|
||||
validateGroupRoster entries =
|
||||
dedup [] $ filter (\RosterMember {role} -> isRosterRole role) entries
|
||||
where
|
||||
dedup _ [] = []
|
||||
dedup seen (rm@RosterMember {memberId} : rms)
|
||||
@@ -1248,8 +1254,8 @@ validateGroupRoster GroupRoster {version = ver, roster = entries} =
|
||||
| otherwise = rm : dedup (memberId : seen) rms
|
||||
|
||||
-- Privileged members without a known key are skipped (recipients can't verify them).
|
||||
buildGroupRoster :: VersionRoster -> [GroupMember] -> GroupRoster
|
||||
buildGroupRoster ver mods = GroupRoster {version = ver, roster = mapMaybe rosterMember mods}
|
||||
buildGroupRoster :: [GroupMember] -> [RosterMember]
|
||||
buildGroupRoster mods = mapMaybe rosterMember mods
|
||||
where
|
||||
rosterMember m@GroupMember {memberId, memberPubKey, memberRole}
|
||||
| isRosterRole memberRole = (\k -> RosterMember {memberId, name = memberShortenedName m, key = MemberKey k, role = memberRole}) <$> memberPubKey
|
||||
@@ -1872,6 +1878,39 @@ closeFileHandle fileId files = do
|
||||
h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m)
|
||||
liftIO $ mapM_ hClose h_ `catchAll_` pure ()
|
||||
|
||||
-- Roster-file cleanup keyed on the group (the roster file has no chat item, so the
|
||||
-- normal chat-item file enumeration misses it and the on-disk file would leak): evict
|
||||
-- the cached handle, remove the on-disk file, delete the rows (rcv_files/chunks cascade),
|
||||
-- and clear the pending columns.
|
||||
cleanupGroupRosterFile :: User -> GroupInfo -> CM ()
|
||||
cleanupGroupRosterFile User {userId} gInfo@GroupInfo {groupId} = do
|
||||
info_ <- withStore' $ \db -> getGroupRosterFileInfo db userId groupId
|
||||
forM_ info_ $ \(fileId, filePath_) -> do
|
||||
lift $ closeFileHandle fileId rcvFiles
|
||||
forM_ filePath_ removeRosterFsFile
|
||||
withStore' $ \db -> do
|
||||
deleteGroupRosterFile db userId groupId
|
||||
clearRosterPending db gInfo
|
||||
|
||||
-- Discard partial roster chunks so the transfer re-drives from chunk 1 (relay restart /
|
||||
-- re-subscribe / QCONT). MUST evict the cached AppendMode handle first, or appended bytes
|
||||
-- land after the stale prefix and corrupt the blob (the digest then fails).
|
||||
resetRosterPartialChunks :: RcvFileTransfer -> CM ()
|
||||
resetRosterPartialChunks ft@RcvFileTransfer {fileId, fileStatus} = do
|
||||
lift $ closeFileHandle fileId rcvFiles
|
||||
forM_ (rcvFilePath fileStatus) removeRosterFsFile
|
||||
withStore' $ \db -> deleteRcvFileChunks db ft
|
||||
where
|
||||
rcvFilePath = \case
|
||||
RFSAccepted p -> Just p
|
||||
RFSConnected p -> Just p
|
||||
_ -> Nothing
|
||||
|
||||
removeRosterFsFile :: FilePath -> CM ()
|
||||
removeRosterFsFile fp = do
|
||||
p <- lift $ toFSFilePath fp
|
||||
removeFile p `catchAllErrors` \_ -> pure ()
|
||||
|
||||
deleteMembersConnections :: User -> [GroupMember] -> CM ()
|
||||
deleteMembersConnections user members = deleteMembersConnections' user members False
|
||||
|
||||
@@ -2176,13 +2215,13 @@ bumpAndBroadcastRoster :: User -> GroupInfo -> CM ()
|
||||
bumpAndBroadcastRoster user gInfo = do
|
||||
vr <- chatVersionRange
|
||||
let rosterVer = maybe (VersionRoster 0) (\(VersionRoster n) -> VersionRoster (n + 1)) (rosterVersion gInfo)
|
||||
(relays, roster) <- withStore' $ \db -> do
|
||||
(relays, mods) <- withStore' $ \db -> do
|
||||
relays <- getGroupRelayMembers db vr user gInfo
|
||||
mods <- getGroupRosterMembers db vr user gInfo
|
||||
setGroupRosterVersion db gInfo rosterVer
|
||||
pure (relays, buildGroupRoster rosterVer mods)
|
||||
pure (relays, mods)
|
||||
forM_ (L.nonEmpty relays) $ \relays' ->
|
||||
void $ sendGroupMessage' user gInfo (L.toList relays') (XGrpRoster roster)
|
||||
sendRoster user gInfo (L.toList relays') rosterVer (buildGroupRoster mods)
|
||||
|
||||
-- Send the current roster (no version bump) to a newly added relay so it can serve joiners.
|
||||
sendGroupRosterToRelay :: User -> GroupInfo -> GroupMember -> CM ()
|
||||
@@ -2190,7 +2229,29 @@ sendGroupRosterToRelay user gInfo relayMember =
|
||||
forM_ (rosterVersion gInfo) $ \rosterVer -> do
|
||||
vr <- chatVersionRange
|
||||
mods <- withStore' $ \db -> getGroupRosterMembers db vr user gInfo
|
||||
void $ sendGroupMessage' user gInfo [relayMember] (XGrpRoster (buildGroupRoster rosterVer mods))
|
||||
sendRoster user gInfo [relayMember] rosterVer (buildGroupRoster mods)
|
||||
|
||||
-- Build the roster blob, send the owner-signed header carrying its size + digest, then
|
||||
-- send the blob as BFileChunks under the header's shared_msg_id. Row-less: no files/
|
||||
-- snd_files rows for the send, so there is no send-side cleanup; redelivery is the agent's.
|
||||
sendRoster :: User -> GroupInfo -> [GroupMember] -> VersionRoster -> [RosterMember] -> CM ()
|
||||
sendRoster user gInfo members rosterVer roster = do
|
||||
let blob = encodeRosterBlob roster
|
||||
fileInv = InlineFileInvitation {fileSize = fromIntegral (B.length blob), fileDigest = FD.FileDigest $ LC.sha512Hash $ LB.fromStrict blob}
|
||||
SndMessage {sharedMsgId} <- sendGroupMessage' user gInfo members (XGrpRoster GroupRoster {version = rosterVer, fileInv})
|
||||
sendRosterBlobChunks user gInfo members sharedMsgId blob
|
||||
|
||||
-- Chunk a roster blob and send each as a BFileChunk under the header's shared_msg_id,
|
||||
-- to the same recipients. Used by both the owner send and the relay re-serve.
|
||||
sendRosterBlobChunks :: User -> GroupInfo -> [GroupMember] -> SharedMsgId -> ByteString -> CM ()
|
||||
sendRosterBlobChunks user gInfo members sharedMsgId blob = do
|
||||
chSize <- fromIntegral <$> asks (fileChunkSize . config)
|
||||
go chSize 1 blob
|
||||
where
|
||||
go chSize chunkNo bytes = do
|
||||
let (chunk, rest) = B.splitAt chSize bytes
|
||||
void $ sendGroupMessage' user gInfo members (BFileChunk sharedMsgId (FileChunk chunkNo chunk))
|
||||
unless (B.null rest) $ go chSize (chunkNo + 1) rest
|
||||
|
||||
sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
|
||||
sendGroupMessages user gInfo scope asGroup members events = do
|
||||
|
||||
@@ -24,6 +24,7 @@ import Control.Monad.IO.Unlift
|
||||
import Control.Monad.Reader
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Either (lefts, partitionEithers, rights)
|
||||
import Data.Foldable (foldr', foldrM)
|
||||
import Data.Functor (($>))
|
||||
@@ -86,8 +87,10 @@ import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||
import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn)
|
||||
import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
||||
import qualified Simplex.Messaging.Crypto.Lazy as LC
|
||||
import Simplex.Messaging.Encoding (smpEncode)
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (parseAll)
|
||||
import Simplex.Messaging.Protocol (ErrorType (..), MsgFlags (..), ServiceSub (..), ServiceSubError (..), ServiceSubResult (..))
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||
@@ -1029,7 +1032,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
pure newDeliveryTasks
|
||||
processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask)
|
||||
processEvent gInfo' m' verifiedMsg = do
|
||||
(m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg
|
||||
(m'', conn', msg@RcvMessage {msgId, sharedMsgId_, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg
|
||||
let ctx js = DeliveryTaskContext js False
|
||||
checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext)
|
||||
checkSendAsGroup asGroup_ a
|
||||
@@ -1078,7 +1081,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs
|
||||
XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs
|
||||
XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg
|
||||
XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' gr verifiedMsg brokerTs
|
||||
XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' gr verifiedMsg sharedMsgId_ brokerTs
|
||||
XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck gInfo' m'' ackVer ackErr
|
||||
-- TODO [knocking] why don't we forward these messages?
|
||||
XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs
|
||||
@@ -2466,10 +2469,55 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
ft <- withStore $ \db -> getDirectFileIdBySharedMsgId db user ct sharedMsgId >>= getRcvFileTransfer db user
|
||||
receiveInlineChunk ft chunk meta
|
||||
|
||||
-- A group BFileChunk is either a roster blob chunk (located by (group_id, shared_msg_id,
|
||||
-- file_type = roster)) or a normal inline file chunk (located via its chat item). An
|
||||
-- orphaned roster chunk that matches no in-flight transfer is ACKed and ignored.
|
||||
bFileChunkGroup :: GroupInfo -> SharedMsgId -> FileChunk -> MsgMeta -> CM ()
|
||||
bFileChunkGroup GroupInfo {groupId} sharedMsgId chunk meta = do
|
||||
ft <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId >>= getRcvFileTransfer db user
|
||||
receiveInlineChunk ft chunk meta
|
||||
bFileChunkGroup gInfo@GroupInfo {groupId} sharedMsgId chunk meta = do
|
||||
rosterFileId_ <- withStore' $ \db -> getGroupRosterFileId db userId groupId sharedMsgId
|
||||
case rosterFileId_ of
|
||||
Just fileId -> do
|
||||
ft <- withStore $ \db -> getRcvFileTransfer db user fileId
|
||||
receiveRosterChunk gInfo ft meta chunk
|
||||
Nothing -> do
|
||||
normalFileId_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupFileIdBySharedMsgId db userId groupId sharedMsgId)
|
||||
case normalFileId_ of
|
||||
Just fileId -> do
|
||||
ft <- withStore $ \db -> getRcvFileTransfer db user fileId
|
||||
receiveInlineChunk ft chunk meta
|
||||
-- orphaned roster chunk (header short-circuited; version already applied/superseded):
|
||||
-- ignore so an up-to-date member tolerates the unconditional re-serve (the outer
|
||||
-- withAckMessage acks the agent message)
|
||||
Nothing -> pure ()
|
||||
|
||||
-- Roster blob receive: reset-on-chunk-1 for a re-driven transfer, then append; the final
|
||||
-- chunk drives completion (verify, apply, promote). No chat item, so no file-start/complete UI.
|
||||
receiveRosterChunk :: GroupInfo -> RcvFileTransfer -> MsgMeta -> FileChunk -> CM ()
|
||||
receiveRosterChunk gInfo ft@RcvFileTransfer {chunkSize} MsgMeta {recipient = (msgId, _), integrity} = \case
|
||||
FileChunkCancel -> cleanupGroupRosterFile user gInfo
|
||||
FileChunk {chunkNo, chunkBytes = chunk} -> do
|
||||
case integrity of
|
||||
MsgOk -> pure ()
|
||||
MsgError MsgDuplicate -> pure ()
|
||||
MsgError e -> badRcvFileChunk ft $ "invalid file chunk number " <> show chunkNo <> ": " <> show e
|
||||
-- a re-driven transfer (relay restart / re-subscribe / QCONT) restarts from the start;
|
||||
-- discard partials so stale bytes can't corrupt the reassembled blob
|
||||
when (chunkNo == 1) $ do
|
||||
last_ <- withStore' $ \db -> getRcvFileLastChunkNo db ft
|
||||
when (isJust last_) $ resetRosterPartialChunks ft
|
||||
-- the outer withAckMessage ("group msg") acks the agent message; roster chunks must NOT
|
||||
-- ack again (the agent message is already acked -> SEMsgNotFound)
|
||||
withStore' (\db -> createRcvFileChunk db ft chunkNo msgId) >>= \case
|
||||
RcvChunkOk
|
||||
| B.length chunk /= fromInteger chunkSize -> badRcvFileChunk ft "incorrect chunk size"
|
||||
| otherwise -> appendFileChunk ft chunkNo chunk False
|
||||
RcvChunkFinal
|
||||
| B.length chunk > fromInteger chunkSize -> badRcvFileChunk ft "incorrect chunk size"
|
||||
| otherwise -> do
|
||||
appendFileChunk ft chunkNo chunk True
|
||||
rosterCompletion gInfo ft
|
||||
RcvChunkDuplicate -> pure ()
|
||||
RcvChunkError -> badRcvFileChunk ft ("incorrect chunk number " <> show chunkNo)
|
||||
|
||||
receiveInlineChunk :: RcvFileTransfer -> FileChunk -> MsgMeta -> CM ()
|
||||
receiveInlineChunk RcvFileTransfer {fileId, fileStatus = RFSNew} FileChunk {chunkNo} _
|
||||
@@ -3192,89 +3240,139 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole, msgSigned}
|
||||
pure $ memberEventDeliveryScope member
|
||||
|
||||
xGrpRoster :: MsgEncodingI e => GroupInfo -> GroupMember -> GroupRoster -> VerifiedMsg e -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xGrpRoster gInfo author roster verifiedMsg brokerTs
|
||||
-- The header only STARTS a transfer: it writes roster_pending_* and creates the
|
||||
-- chat-item-free rcv file. It never applies the roster or bumps the version (that
|
||||
-- happens at blob completion, so a withheld/corrupted blob leaves the last roster intact).
|
||||
xGrpRoster :: GroupInfo -> GroupMember -> GroupRoster -> VerifiedMsg e -> Maybe SharedMsgId -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xGrpRoster gInfo author GroupRoster {version = newVer, fileInv = InlineFileInvitation {fileSize, fileDigest}} verifiedMsg sharedMsgId_ brokerTs
|
||||
-- only an owner may sign a roster; otherwise a relay could route it as a member whose key it controls
|
||||
| memberRole' author /= GROwner = messageError "x.grp.roster: not signed by an owner" $> Nothing
|
||||
| length entries > maxGroupRosterSize = messageError ("x.grp.roster: too many entries, max " <> tshow maxGroupRosterSize) $> Nothing
|
||||
| isUserGrpFwdRelay gInfo = relayApplyRoster
|
||||
| otherwise = Nothing <$ memberApplyRoster
|
||||
| otherwise = case (verifiedMsg, sharedMsgId_) of
|
||||
(VMUnsigned _, _) -> Nothing <$ messageWarning "x.grp.roster: unsigned roster"
|
||||
(_, Nothing) -> Nothing <$ messageWarning "x.grp.roster: missing shared message id"
|
||||
(VMSigned _ sm _, Just sharedMsgId) -> do
|
||||
pendingVer_ <- fmap (\(v, _, _, _, _) -> v) <$> withStore' (\db -> getRosterPending db gInfo)
|
||||
-- start only for a version strictly greater than BOTH applied and pending (Nothing < 0):
|
||||
-- a relay's first v0 from NULL applies; a re-receive at Just 0 short-circuits; and an
|
||||
-- unconditional re-serve of a cached older version cannot supersede a newer in-flight one.
|
||||
if newVer `aboveRoster` rosterVersion gInfo && newVer `aboveRoster` pendingVer_
|
||||
then startRosterTransfer sm sharedMsgId
|
||||
-- silent: re-serves of the current version are routine (broadcast / SENT / QCONT)
|
||||
else pure Nothing
|
||||
where
|
||||
GroupRoster {version = newVer, roster = entries} = validRoster
|
||||
validRoster = validateGroupRoster roster
|
||||
relayApplyRoster :: CM (Maybe DeliveryJobScope)
|
||||
relayApplyRoster
|
||||
| maybe False (newVer <=) (rosterVersion gInfo) = Nothing <$ messageWarning "x.grp.roster: not newer than saved version"
|
||||
| otherwise = case verifiedMsg of
|
||||
VMSigned _ sm _ ->
|
||||
tryAllErrors (setRoster sm) >>= \case
|
||||
Right results -> do
|
||||
emitRosterResults results
|
||||
-- ack only while still setting up (own status RSAccepted); a serving relay (RSActive) must not ack roster broadcasts
|
||||
when (relayOwnStatus gInfo == Just RSAccepted) $ sendRosterAck author newVer Nothing
|
||||
-- always broadcast on a bump: self-healing, and demotions must reach members
|
||||
pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}
|
||||
Left e -> do
|
||||
eToView e
|
||||
when (relayOwnStatus gInfo == Just RSAccepted) $ sendRosterAck author newVer (Just "relay could not save the roster")
|
||||
pure Nothing
|
||||
VMUnsigned _ -> Nothing <$ messageWarning "x.grp.roster: unsigned roster"
|
||||
setRoster :: SignedMsg -> CM ([MemberId], [(GroupMember, GroupMemberRole)])
|
||||
setRoster sm = do
|
||||
defaultRole <- unknownMemberRole gInfo
|
||||
withStore $ \db -> do
|
||||
res <- processRoster db defaultRole
|
||||
liftIO $ setGroupRoster db gInfo newVer (groupMemberId' author) brokerTs sm
|
||||
pure res
|
||||
memberApplyRoster :: CM ()
|
||||
memberApplyRoster
|
||||
| maybe False (newVer <) (rosterVersion gInfo) = messageWarning "x.grp.roster: older than accepted version"
|
||||
| maybe False (newVer ==) (rosterVersion gInfo) = pure ()
|
||||
| otherwise = do
|
||||
defaultRole <- unknownMemberRole gInfo
|
||||
results <- withStore $ \db -> do
|
||||
res <- processRoster db defaultRole
|
||||
liftIO $ setGroupRosterVersion db gInfo newVer
|
||||
pure res
|
||||
emitRosterResults results
|
||||
processRoster :: DB.Connection -> GroupMemberRole -> ExceptT StoreError IO ([MemberId], [(GroupMember, GroupMemberRole)])
|
||||
processRoster db defaultRole = do
|
||||
let rosterIds = map (\RosterMember {memberId} -> memberId) entries
|
||||
acc <- foldrM applyRosterEntry ([], []) entries
|
||||
-- absent privileged members revert to the joiner default
|
||||
currentPriv <- liftIO $ getGroupRosterMembers db vr user gInfo
|
||||
liftIO $ forM_ currentPriv $ \m ->
|
||||
when (memberId' m `notElem` rosterIds) $
|
||||
updateGroupMemberRole db user m defaultRole
|
||||
pure acc
|
||||
startRosterTransfer sm sharedMsgId = do
|
||||
-- supersede any in-flight roster file (older version or a restart) before the new transfer
|
||||
cleanupGroupRosterFile user gInfo
|
||||
let relayHdr = if isUserGrpFwdRelay gInfo then Just sm else Nothing
|
||||
withStore' $ \db -> setRosterPending db gInfo newVer fileDigest (groupMemberId' author) brokerTs relayHdr
|
||||
chSize <- asks $ fileChunkSize . config
|
||||
rft@RcvFileTransfer {fileId} <- withStore' $ \db -> createGroupRosterRcvFile db userId gInfo sharedMsgId fileSize (fromIntegral chSize)
|
||||
-- accept the chat-item-free file before chunk 1 (FIFO before it) so chunk 1 isn't rejected on RFSNew
|
||||
filePath <- getRcvFilePath fileId Nothing "roster" False
|
||||
withStore' $ \db -> startRcvInlineFT db user rft filePath (Just IFMSent)
|
||||
pure Nothing
|
||||
|
||||
-- Roster version comparison treating Nothing (un-materialized) as below 0.
|
||||
aboveRoster :: VersionRoster -> Maybe VersionRoster -> Bool
|
||||
aboveRoster v = maybe True (v >)
|
||||
|
||||
-- The roster blob has fully arrived: verify the owner-attested digest over the plaintext,
|
||||
-- guard the version (no downgrade), apply, then in one DB transaction promote version + blob
|
||||
-- (and, on a relay, the live header). On a relay also ack the owner and re-serve to members.
|
||||
rosterCompletion :: GroupInfo -> RcvFileTransfer -> CM ()
|
||||
rosterCompletion gInfo RcvFileTransfer {fileStatus} =
|
||||
withStore' (\db -> getRosterPending db gInfo) >>= \case
|
||||
Nothing -> cleanupGroupRosterFile user gInfo
|
||||
Just (pendingVer, pendingDigest, ownerGMId, rosterBrokerTs, _) -> do
|
||||
owner_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberById db vr user ownerGMId)
|
||||
blob <- readAssembledRoster
|
||||
let isRelay = isUserGrpFwdRelay gInfo
|
||||
ackErr err = do
|
||||
cleanupGroupRosterFile user gInfo
|
||||
when isRelay $ forM_ owner_ $ \owner -> sendRosterAck gInfo owner pendingVer (Just err)
|
||||
if FD.FileDigest (LC.sha512Hash (LB.fromStrict blob)) /= pendingDigest
|
||||
then ackErr "relay could not verify the roster blob"
|
||||
else case parseAll rosterBlobP blob of
|
||||
Left _ -> ackErr "relay could not parse the roster blob"
|
||||
Right entries
|
||||
-- stale/out-of-order completion: reject, never downgrade
|
||||
| not (pendingVer `aboveRoster` rosterVersion gInfo) -> cleanupGroupRosterFile user gInfo
|
||||
| otherwise -> case owner_ of
|
||||
Nothing -> cleanupGroupRosterFile user gInfo
|
||||
Just author -> do
|
||||
defaultRole <- unknownMemberRole gInfo
|
||||
results <- withStore $ \db -> do
|
||||
res <- processRosterEntries db gInfo defaultRole (validateGroupRoster entries)
|
||||
liftIO $ promoteRosterPending db gInfo blob
|
||||
pure res
|
||||
cleanupGroupRosterFile user gInfo
|
||||
emitRosterResults gInfo author rosterBrokerTs results
|
||||
when isRelay $ do
|
||||
-- ack only while still setting up (own status RSAccepted); a serving relay must not ack broadcasts
|
||||
when (relayOwnStatus gInfo == Just RSAccepted) $ sendRosterAck gInfo author pendingVer Nothing
|
||||
-- broadcast on a bump: self-healing, and demotions must reach members.
|
||||
-- re-serve via forwardGroupRoster (header + blob), not a delivery task: a
|
||||
-- BFileChunk body is not a forwardable JSON message.
|
||||
broadcastRoster gInfo
|
||||
where
|
||||
readAssembledRoster = case fileStatus of
|
||||
RFSAccepted fp -> readAt fp
|
||||
RFSConnected fp -> readAt fp
|
||||
RFSComplete fp -> readAt fp
|
||||
_ -> throwChatError $ CEInternalError "roster file not in progress"
|
||||
readAt fp = lift (toFSFilePath fp) >>= liftIO . B.readFile
|
||||
|
||||
-- Push the freshly applied roster to the relay's member subscribers (header + blob each).
|
||||
broadcastRoster :: GroupInfo -> CM ()
|
||||
broadcastRoster gInfo = do
|
||||
members <- withStore' $ \db -> getGroupMembers db vr user gInfo
|
||||
forM_ (filter rosterRecipient members) $ \m ->
|
||||
forwardGroupRoster user gInfo m `catchAllErrors` eToView
|
||||
where
|
||||
rosterRecipient m@GroupMember {activeConn} = memberCurrent m && isJust activeConn && not (isRelay m) && memberRole' m /= GROwner
|
||||
|
||||
-- TOFU apply over the parsed roster entries (unchanged logic, lifted from the old header
|
||||
-- handler): create/find members, pin keys, update roles, revert absent privileged members.
|
||||
processRosterEntries :: DB.Connection -> GroupInfo -> GroupMemberRole -> [RosterMember] -> ExceptT StoreError IO ([MemberId], [(GroupMember, GroupMemberRole)])
|
||||
processRosterEntries db gInfo defaultRole entries = do
|
||||
let rosterIds = map (\RosterMember {memberId} -> memberId) entries
|
||||
acc <- foldrM applyRosterEntry ([], []) entries
|
||||
-- absent privileged members revert to the joiner default
|
||||
currentPriv <- liftIO $ getGroupRosterMembers db vr user gInfo
|
||||
liftIO $ forM_ currentPriv $ \m ->
|
||||
when (memberId' m `notElem` rosterIds) $
|
||||
updateGroupMemberRole db user m defaultRole
|
||||
pure acc
|
||||
where
|
||||
-- entry-level failure (StoreError or IO exception) is muted; the entry is dropped
|
||||
applyRosterEntry RosterMember {memberId, name, key = MemberKey pubKey, role} (cs, as) =
|
||||
apply `catchAllErrors` \_ -> pure (cs, as)
|
||||
where
|
||||
-- entry-level failure (StoreError or IO exception) is muted; the entry is dropped
|
||||
applyRosterEntry RosterMember {memberId, name, key = MemberKey pubKey, role} (cs, as) =
|
||||
apply `catchAllErrors` \_ -> pure (cs, as)
|
||||
where
|
||||
applied m = (cs, ((m :: GroupMember) {memberRole = role}, memberRole' m) : as)
|
||||
apply = getCreateUnknownGMByMemberId db vr user gInfo memberId name defaultRole True >>= \case
|
||||
Nothing -> pure (cs, as)
|
||||
Just (m, _) -> case memberPubKey m of
|
||||
Just k
|
||||
| k /= pubKey -> pure (memberId : cs, as)
|
||||
| memberRole' m == role -> pure (cs, as)
|
||||
| otherwise -> liftIO (updateGroupMemberRole db user m role) $> applied m
|
||||
Nothing -> liftIO (setGroupMemberKeyRole db m pubKey role) $> applied m
|
||||
emitRosterResults :: ([MemberId], [(GroupMember, GroupMemberRole)]) -> CM ()
|
||||
emitRosterResults (conflicts, applied) = do
|
||||
forM_ conflicts $ \mid' ->
|
||||
messageWarning $ "x.grp.roster: member key conflict, keeping trusted key, memberId=" <> safeDecodeUtf8 (strEncode mid')
|
||||
forM_ applied $ \(member, fromRole) -> createItems member fromRole
|
||||
createItems :: GroupMember -> GroupMemberRole -> CM ()
|
||||
applied m = (cs, ((m :: GroupMember) {memberRole = role}, memberRole' m) : as)
|
||||
apply = getCreateUnknownGMByMemberId db vr user gInfo memberId name defaultRole True >>= \case
|
||||
Nothing -> pure (cs, as)
|
||||
Just (m, _) -> case memberPubKey m of
|
||||
Just k
|
||||
| k /= pubKey -> pure (memberId : cs, as)
|
||||
| memberRole' m == role -> pure (cs, as)
|
||||
| otherwise -> liftIO (updateGroupMemberRole db user m role) $> applied m
|
||||
Nothing -> liftIO (setGroupMemberKeyRole db m pubKey role) $> applied m
|
||||
|
||||
emitRosterResults :: GroupInfo -> GroupMember -> UTCTime -> ([MemberId], [(GroupMember, GroupMemberRole)]) -> CM ()
|
||||
emitRosterResults gInfo author rosterBrokerTs (conflicts, applied) = do
|
||||
forM_ conflicts $ \mid' ->
|
||||
messageWarning $ "x.grp.roster: member key conflict, keeping trusted key, memberId=" <> safeDecodeUtf8 (strEncode mid')
|
||||
forM_ applied $ \(member, fromRole) -> createItems member fromRole
|
||||
where
|
||||
createItems member fromRole = do
|
||||
let toRole = memberRole' member
|
||||
gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) toRole
|
||||
(gInfo', author', scopeInfo) <- mkGroupChatScope gInfo author
|
||||
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo author') (CIRcvGroupEvent gEvent) (Just brokerTs)
|
||||
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo author') (CIRcvGroupEvent gEvent) (Just rosterBrokerTs)
|
||||
toView CEvtMemberRole {user, groupInfo = gInfo', byMember = author', member, fromRole, toRole, msgSigned = Just MSSVerified}
|
||||
sendRosterAck :: GroupMember -> VersionRoster -> Maybe Text -> CM ()
|
||||
sendRosterAck owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err)
|
||||
|
||||
sendRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM ()
|
||||
sendRosterAck gInfo owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err)
|
||||
|
||||
xGrpRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM ()
|
||||
xGrpRosterAck gInfo m ackVer err = do
|
||||
@@ -3608,7 +3706,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
processForwardedMsg :: VerifiedMsg 'Json -> Maybe GroupMember -> CM ()
|
||||
processForwardedMsg verifiedMsg author_ = do
|
||||
rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author_ verifiedMsg brokerTs
|
||||
forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of
|
||||
forM_ rcvMsg_ $ \rcvMsg@RcvMessage {sharedMsgId_, chatMsgEvent = ACME _ event} -> case event of
|
||||
XMsgNew mc ->
|
||||
void $ memberCanSend author_ scope $ newGroupContentMessage gInfo author_ mc rcvMsg msgTs True
|
||||
where
|
||||
@@ -3630,7 +3728,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs
|
||||
XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs
|
||||
XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg
|
||||
XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo author gr verifiedMsg msgTs
|
||||
XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo author gr verifiedMsg sharedMsgId_ msgTs
|
||||
_ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event)
|
||||
where
|
||||
withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM ()
|
||||
|
||||
@@ -48,12 +48,13 @@ import Data.Time.Clock (UTCTime)
|
||||
import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime)
|
||||
import Data.Type.Equality
|
||||
import Data.Typeable (Typeable)
|
||||
import Data.Word (Word32)
|
||||
import Data.Word (Word16, Word32)
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Chat.Types.Shared
|
||||
import qualified Simplex.FileTransfer.Description as FD
|
||||
import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion)
|
||||
import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_)
|
||||
import Simplex.Messaging.Compression (Compressed, compress1, decompress1, decompressedSize)
|
||||
@@ -367,11 +368,21 @@ data GrpMsgForward = GrpMsgForward
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Owner-signed snapshot of the privileged (moderator/admin) set; owners are
|
||||
-- not included, their keys come from the link.
|
||||
-- | Owner-signed roster header for the privileged (moderator/admin) set; owners
|
||||
-- are not included, their keys come from the link. The member list itself is not
|
||||
-- here: it is sent as a binary blob over the inline file transfer, and this header
|
||||
-- carries only its inline-file invitation (size + owner-attested digest).
|
||||
data GroupRoster = GroupRoster
|
||||
{ version :: VersionRoster,
|
||||
roster :: [RosterMember]
|
||||
fileInv :: InlineFileInvitation
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Lean always-inline file invitation for the roster blob, carried in the signed
|
||||
-- header. The digest authenticates the unsigned blob; integrity is entirely the digest.
|
||||
data InlineFileInvitation = InlineFileInvitation
|
||||
{ fileSize :: Integer,
|
||||
fileDigest :: FD.FileDigest
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
@@ -383,6 +394,13 @@ data RosterMember = RosterMember
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- RosterMember is binary-only: it rides in the roster blob, never in a JSON message.
|
||||
-- The blob codec (encodeRosterBlob / rosterBlobP) is defined with maxGroupRosterSize below,
|
||||
-- so rosterBlobP and the bound it references sit in one declaration group (past the TH splices).
|
||||
instance Encoding RosterMember where
|
||||
smpEncode RosterMember {memberId, name, key, role} = smpEncode (memberId, name, key, role)
|
||||
smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP
|
||||
|
||||
instance Encoding FwdSender where
|
||||
smpEncode = \case
|
||||
FwdMember memberId memberName -> smpEncode ('M', memberId, memberName)
|
||||
@@ -809,7 +827,7 @@ data MsgMention = MsgMention {memberId :: MemberId}
|
||||
newtype MsgMentions = MsgMentions (Map MemberName MsgMention)
|
||||
deriving (Eq, Show)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''RosterMember)
|
||||
$(JQ.deriveJSON defaultJSON ''InlineFileInvitation)
|
||||
|
||||
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MCL") ''MsgChatLink)
|
||||
|
||||
@@ -911,12 +929,23 @@ maxCompressedMsgLength = 13380
|
||||
maxDecompressedMsgLength :: Int
|
||||
maxDecompressedMsgLength = 65536
|
||||
|
||||
-- Bound so the signed roster fits maxEncodedMsgLength (15602 B): ~140 B per
|
||||
-- RosterMember (memberId + Ed25519 key both base64, role, name capped at 16
|
||||
-- chars by memberShortenedName). Fits 64 entries even with 4-byte-UTF-8 names.
|
||||
-- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the
|
||||
-- promotion cap over the privileged (moderator/admin) set. The blob rides over the
|
||||
-- inline file transfer, so it is no longer bound by maxEncodedMsgLength.
|
||||
maxGroupRosterSize :: Int
|
||||
maxGroupRosterSize = 64
|
||||
|
||||
-- The byte sequence the owner-signed digest is computed over and verified against
|
||||
-- before parsing. Word16 count (smpEncodeList's 1-byte count is too small for the future cap).
|
||||
encodeRosterBlob :: [RosterMember] -> ByteString
|
||||
encodeRosterBlob ms = smpEncode (fromIntegral (length ms) :: Word16) <> B.concat (map smpEncode ms)
|
||||
|
||||
rosterBlobP :: A.Parser [RosterMember]
|
||||
rosterBlobP = do
|
||||
n <- fromIntegral <$> smpP @Word16
|
||||
when (n > maxGroupRosterSize) $ fail "roster: too many entries"
|
||||
A.count n smpP
|
||||
|
||||
-- maxEncodedMsgLength - delta between MSG and INFO + 100 (returned for forward overhead)
|
||||
-- delta between MSG and INFO = e2eEncUserMsgLength (no PQ) - e2eEncConnInfoLength (no PQ) = 1008
|
||||
maxEncodedInfoLength :: Int
|
||||
@@ -1398,7 +1427,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
|
||||
XGrpInfo_ -> XGrpInfo <$> p "groupProfile"
|
||||
XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences"
|
||||
XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope"
|
||||
XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "roster")
|
||||
XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "fileInv")
|
||||
XGrpRosterAck_ -> XGrpRosterAck <$> p "version" <*> opt "error"
|
||||
XGrpMsgForward_ -> do
|
||||
fwdSender <- opt "memberId" >>= \case
|
||||
@@ -1472,7 +1501,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
|
||||
XGrpInfo p -> o ["groupProfile" .= p]
|
||||
XGrpPrefs p -> o ["groupPreferences" .= p]
|
||||
XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq]
|
||||
XGrpRoster GroupRoster {version, roster} -> o ["version" .= version, "roster" .= roster]
|
||||
XGrpRoster GroupRoster {version, fileInv} -> o ["version" .= version, "fileInv" .= fileInv]
|
||||
XGrpRosterAck version err -> o $ ("error" .=? err) ["version" .= version]
|
||||
XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} msg -> o $ encodeFwdSender fwdSender ["msg" .= msg, "msgTs" .= fwdBrokerTs]
|
||||
where
|
||||
|
||||
@@ -31,6 +31,11 @@ module Simplex.Chat.Store.Files
|
||||
getSharedMsgIdByFileId,
|
||||
getFileIdBySharedMsgId,
|
||||
getGroupFileIdBySharedMsgId,
|
||||
getGroupRosterFileId,
|
||||
createGroupRosterRcvFile,
|
||||
getGroupRosterFileInfo,
|
||||
deleteGroupRosterFile,
|
||||
getRcvFileLastChunkNo,
|
||||
getDirectFileIdBySharedMsgId,
|
||||
getChatRefByFileId,
|
||||
lookupChatRefByFileId,
|
||||
@@ -320,6 +325,79 @@ getGroupFileIdBySharedMsgId db userId groupId sharedMsgId =
|
||||
|]
|
||||
(userId, groupId, sharedMsgId)
|
||||
|
||||
-- The roster blob file is located by (group_id, shared_msg_id, file_type = roster),
|
||||
-- not by a chat item (it has none). Nothing => no in-flight roster transfer (orphaned
|
||||
-- chunk to an up-to-date member), which the caller ACKs and ignores.
|
||||
getGroupRosterFileId :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> IO (Maybe Int64)
|
||||
getGroupRosterFileId db userId groupId sharedMsgId =
|
||||
maybeFirstRow fromOnly $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT file_id FROM files
|
||||
WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND file_type = ?
|
||||
|]
|
||||
(userId, groupId, sharedMsgId, FTRoster)
|
||||
|
||||
-- Chat-item-free received file for the roster blob: cryptoArgs are never set (the
|
||||
-- blob is verified as plaintext against the owner-signed digest), file_type = roster,
|
||||
-- located by the header's shared_msg_id.
|
||||
createGroupRosterRcvFile :: DB.Connection -> UserId -> GroupInfo -> SharedMsgId -> Integer -> Integer -> IO RcvFileTransfer
|
||||
createGroupRosterRcvFile db userId GroupInfo {groupId, localDisplayName = gName} sharedMsgId fileSize chunkSize = do
|
||||
currentTs <- getCurrentTime
|
||||
fileId <- do
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO files
|
||||
(user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
(userId, groupId, rosterFileName, fileSize, chunkSize, Just IFMSent, CIFSRcvInvitation, FPSMP, FTRoster, sharedMsgId, currentTs, currentTs)
|
||||
insertedRowId db
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO rcv_files (file_id, file_status, file_inline, rcv_file_inline, created_at, updated_at) VALUES (?,?,?,?,?,?)"
|
||||
(fileId, FSNew, Just IFMSent, Just IFMSent, currentTs, currentTs)
|
||||
pure
|
||||
RcvFileTransfer
|
||||
{ fileId,
|
||||
xftpRcvFile = Nothing,
|
||||
fileInvitation = FileInvitation {fileName = rosterFileName, fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Just IFMSent, fileDescr = Nothing},
|
||||
fileStatus = RFSNew,
|
||||
fileType = FTRoster,
|
||||
rcvFileInline = Just IFMSent,
|
||||
senderDisplayName = gName,
|
||||
chunkSize,
|
||||
cancelled = False,
|
||||
grpMemberId = Nothing,
|
||||
cryptoArgs = Nothing
|
||||
}
|
||||
where
|
||||
rosterFileName = "roster"
|
||||
|
||||
-- For roster-file cleanup keyed on the group (not a chat item): the file_id and its
|
||||
-- on-disk path, so the caller can evict the cached handle and remove the file.
|
||||
getGroupRosterFileInfo :: DB.Connection -> UserId -> Int64 -> IO (Maybe (Int64, Maybe FilePath))
|
||||
getGroupRosterFileInfo db userId groupId =
|
||||
maybeFirstRow id $
|
||||
DB.query
|
||||
db
|
||||
"SELECT file_id, file_path FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?"
|
||||
(userId, groupId, FTRoster)
|
||||
|
||||
-- Deletes the roster files row; rcv_files and rcv_file_chunks cascade on the FK.
|
||||
deleteGroupRosterFile :: DB.Connection -> UserId -> Int64 -> IO ()
|
||||
deleteGroupRosterFile db userId groupId =
|
||||
DB.execute db "DELETE FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?" (userId, groupId, FTRoster)
|
||||
|
||||
-- The highest stored chunk number, or Nothing if no partial chunks exist (used to decide
|
||||
-- whether an arriving chunk 1 is a re-driven transfer that must reset).
|
||||
getRcvFileLastChunkNo :: DB.Connection -> RcvFileTransfer -> IO (Maybe Integer)
|
||||
getRcvFileLastChunkNo db RcvFileTransfer {fileId} =
|
||||
maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT chunk_number FROM rcv_file_chunks WHERE file_id = ? ORDER BY chunk_number DESC LIMIT 1" (Only fileId)
|
||||
|
||||
getDirectFileIdBySharedMsgId :: DB.Connection -> User -> Contact -> SharedMsgId -> ExceptT StoreError IO Int64
|
||||
getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId =
|
||||
ExceptT . firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $
|
||||
@@ -378,7 +456,7 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File
|
||||
db
|
||||
"INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)"
|
||||
(fileId, FSNew, fileConnReq, fileInline, rcvFileInline, rfdId, currentTs, currentTs)
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing}
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing}
|
||||
|
||||
createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer
|
||||
createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do
|
||||
@@ -401,7 +479,7 @@ createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gNam
|
||||
db
|
||||
"INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
|
||||
(fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, rfdId, currentTs, currentTs)
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing}
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing}
|
||||
|
||||
createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64
|
||||
createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do
|
||||
@@ -530,7 +608,7 @@ getRcvFileTransfer_ db userId fileId = do
|
||||
SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name,
|
||||
f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name,
|
||||
f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline,
|
||||
r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name
|
||||
r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type
|
||||
FROM rcv_files r
|
||||
JOIN files f USING (file_id)
|
||||
LEFT JOIN contacts cs ON cs.contact_id = f.contact_id
|
||||
@@ -544,9 +622,9 @@ getRcvFileTransfer_ db userId fileId = do
|
||||
where
|
||||
rcvFileTransfer ::
|
||||
Maybe RcvFileDescr ->
|
||||
(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. Only (Maybe ContactName) ->
|
||||
(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe ContactName, FileType) ->
|
||||
ExceptT StoreError IO RcvFileTransfer
|
||||
rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. Only groupName_) =
|
||||
rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (groupName_, fileType)) =
|
||||
case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of
|
||||
Nothing -> throwError $ SERcvFileInvalid fileId
|
||||
Just name ->
|
||||
@@ -564,7 +642,7 @@ getRcvFileTransfer_ db userId fileId = do
|
||||
let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing}
|
||||
cryptoArgs = CFArgs <$> fileKey <*> fileNonce
|
||||
xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_
|
||||
in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs}
|
||||
in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs}
|
||||
filePath = case filePath_ of
|
||||
Nothing -> throwError $ SERcvFileInvalid fileId
|
||||
Just fp -> pure fp
|
||||
|
||||
@@ -89,6 +89,11 @@ module Simplex.Chat.Store.Groups
|
||||
setGroupRosterVersion,
|
||||
setGroupRoster,
|
||||
getGroupRoster,
|
||||
getRosterBlob,
|
||||
setRosterPending,
|
||||
getRosterPending,
|
||||
promoteRosterPending,
|
||||
clearRosterPending,
|
||||
setGroupMemberKeyRole,
|
||||
createRelayForOwner,
|
||||
getCreateRelayForMember,
|
||||
@@ -218,6 +223,7 @@ import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Types.UITheme
|
||||
import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), UserId)
|
||||
import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow)
|
||||
import qualified Simplex.FileTransfer.Description as FD
|
||||
import Simplex.Messaging.Encoding (smpDecode, smpEncode)
|
||||
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
|
||||
import Simplex.Messaging.Agent.Store.Entity (DBEntityId)
|
||||
@@ -1440,6 +1446,102 @@ getGroupRoster db GroupInfo {groupId} =
|
||||
(\sigs -> (ownerGMId, brokerTs, SignedMsg cb sigs body)) <$> eitherToMaybe (smpDecode sigsBs)
|
||||
toRoster _ = Nothing
|
||||
|
||||
-- The durable completed roster blob a relay re-serves to joiners.
|
||||
getRosterBlob :: DB.Connection -> GroupInfo -> IO (Maybe ByteString)
|
||||
getRosterBlob db GroupInfo {groupId} = do
|
||||
r <- maybeFirstRow fromOnly $ DB.query db "SELECT roster_blob FROM groups WHERE group_id = ?" (Only groupId)
|
||||
pure $ case r of
|
||||
Just (Just (Binary b)) -> Just b
|
||||
_ -> Nothing
|
||||
|
||||
-- In-flight roster transfer state. Version, digest, the sending owner's member id and
|
||||
-- broker ts are always stored (completion needs the owner to attribute chat items and to
|
||||
-- ack). The signed-header body/binding/signatures are relay-only (Nothing on a member),
|
||||
-- and are promoted to the live header at completion so the relay can re-forward it.
|
||||
setRosterPending :: DB.Connection -> GroupInfo -> VersionRoster -> FD.FileDigest -> GroupMemberId -> UTCTime -> Maybe SignedMsg -> IO ()
|
||||
setRosterPending db GroupInfo {groupId} v digest ownerGMId brokerTs sm_ = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE groups
|
||||
SET roster_pending_version = ?, roster_pending_digest = ?,
|
||||
roster_pending_sending_owner_gm_id = ?, roster_pending_broker_ts = ?,
|
||||
roster_pending_msg_chat_binding = ?, roster_pending_msg_signatures = ?, roster_pending_msg_body = ?,
|
||||
updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|]
|
||||
((v, Binary (FD.unFileDigest digest), ownerGMId, brokerTs)
|
||||
:. ((\SignedMsg {chatBinding} -> chatBinding) <$> sm_, (\SignedMsg {signatures} -> Binary (smpEncode signatures)) <$> sm_, (\SignedMsg {signedBody} -> Binary signedBody) <$> sm_, currentTs, groupId))
|
||||
|
||||
getRosterPending :: DB.Connection -> GroupInfo -> IO (Maybe (VersionRoster, FD.FileDigest, GroupMemberId, UTCTime, Maybe SignedMsg))
|
||||
getRosterPending db GroupInfo {groupId} =
|
||||
(>>= toPending)
|
||||
<$> maybeFirstRow
|
||||
id
|
||||
( DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT roster_pending_version, roster_pending_digest,
|
||||
roster_pending_sending_owner_gm_id, roster_pending_broker_ts,
|
||||
roster_pending_msg_chat_binding, roster_pending_msg_signatures, roster_pending_msg_body
|
||||
FROM groups WHERE group_id = ?
|
||||
|]
|
||||
(Only groupId)
|
||||
)
|
||||
where
|
||||
toPending (Just v, Just (Binary d), Just ownerGMId, Just brokerTs, cb_, sigs_, body_) =
|
||||
Just (v, FD.FileDigest d, ownerGMId, brokerTs, sm_)
|
||||
where
|
||||
sm_ = case (cb_, sigs_, body_) of
|
||||
(Just cb, Just (Binary sigsBs), Just (Binary body)) ->
|
||||
(\sigs -> SignedMsg cb sigs body) <$> eitherToMaybe (smpDecode sigsBs)
|
||||
_ -> Nothing
|
||||
toPending _ = Nothing
|
||||
|
||||
-- Completion promotion in one statement: copy pending header -> live, store the
|
||||
-- verified blob, bump version, clear pending. roster_blob and live roster_msg_* move together.
|
||||
promoteRosterPending :: DB.Connection -> GroupInfo -> ByteString -> IO ()
|
||||
promoteRosterPending db GroupInfo {groupId} blob = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE groups SET
|
||||
roster_version = roster_pending_version,
|
||||
roster_blob = ?,
|
||||
roster_sending_owner_gm_id = roster_pending_sending_owner_gm_id,
|
||||
roster_broker_ts = roster_pending_broker_ts,
|
||||
roster_msg_chat_binding = roster_pending_msg_chat_binding,
|
||||
roster_msg_signatures = roster_pending_msg_signatures,
|
||||
roster_msg_body = roster_pending_msg_body,
|
||||
roster_pending_version = NULL,
|
||||
roster_pending_digest = NULL,
|
||||
roster_pending_sending_owner_gm_id = NULL,
|
||||
roster_pending_broker_ts = NULL,
|
||||
roster_pending_msg_chat_binding = NULL,
|
||||
roster_pending_msg_signatures = NULL,
|
||||
roster_pending_msg_body = NULL,
|
||||
updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|]
|
||||
(Binary blob, currentTs, groupId)
|
||||
|
||||
clearRosterPending :: DB.Connection -> GroupInfo -> IO ()
|
||||
clearRosterPending db GroupInfo {groupId} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE groups SET
|
||||
roster_pending_version = NULL, roster_pending_digest = NULL,
|
||||
roster_pending_sending_owner_gm_id = NULL, roster_pending_broker_ts = NULL,
|
||||
roster_pending_msg_chat_binding = NULL, roster_pending_msg_signatures = NULL, roster_pending_msg_body = NULL,
|
||||
updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|]
|
||||
(currentTs, groupId)
|
||||
|
||||
setGroupMemberKeyRole :: DB.Connection -> GroupMember -> C.PublicKeyEd25519 -> GroupMemberRole -> IO ()
|
||||
setGroupMemberKeyRole db GroupMember {groupMemberId} pubKey role = do
|
||||
currentTs <- getCurrentTime
|
||||
|
||||
@@ -15,11 +15,33 @@ ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg_signatures BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id BIGINT;
|
||||
ALTER TABLE groups ADD COLUMN roster_broker_ts TIMESTAMPTZ;
|
||||
ALTER TABLE groups ADD COLUMN roster_blob BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_version INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_digest BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_body BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_chat_binding TEXT;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_signatures BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_sending_owner_gm_id BIGINT;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_broker_ts TIMESTAMPTZ;
|
||||
ALTER TABLE files ADD COLUMN shared_msg_id BYTEA;
|
||||
ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal';
|
||||
CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id);
|
||||
|]
|
||||
|
||||
down_m20260601_group_roster :: Text
|
||||
down_m20260601_group_roster =
|
||||
[r|
|
||||
DROP INDEX IF EXISTS idx_files_group_id_shared_msg_id;
|
||||
ALTER TABLE files DROP COLUMN file_type;
|
||||
ALTER TABLE files DROP COLUMN shared_msg_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_broker_ts;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_sending_owner_gm_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_signatures;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_chat_binding;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_body;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_digest;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_version;
|
||||
ALTER TABLE groups DROP COLUMN roster_blob;
|
||||
ALTER TABLE groups DROP COLUMN roster_broker_ts;
|
||||
ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_msg_signatures;
|
||||
|
||||
@@ -14,11 +14,33 @@ ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg_signatures BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_broker_ts TEXT;
|
||||
ALTER TABLE groups ADD COLUMN roster_blob BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_version INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_digest BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_body BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_chat_binding TEXT;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_signatures BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_sending_owner_gm_id INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_broker_ts TEXT;
|
||||
ALTER TABLE files ADD COLUMN shared_msg_id BLOB;
|
||||
ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal';
|
||||
CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id);
|
||||
|]
|
||||
|
||||
down_m20260601_group_roster :: Query
|
||||
down_m20260601_group_roster =
|
||||
[sql|
|
||||
DROP INDEX IF EXISTS idx_files_group_id_shared_msg_id;
|
||||
ALTER TABLE files DROP COLUMN file_type;
|
||||
ALTER TABLE files DROP COLUMN shared_msg_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_broker_ts;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_sending_owner_gm_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_signatures;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_chat_binding;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_body;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_digest;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_version;
|
||||
ALTER TABLE groups DROP COLUMN roster_blob;
|
||||
ALTER TABLE groups DROP COLUMN roster_broker_ts;
|
||||
ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_msg_signatures;
|
||||
|
||||
@@ -188,7 +188,15 @@ CREATE TABLE groups(
|
||||
roster_msg_chat_binding TEXT,
|
||||
roster_msg_signatures BLOB,
|
||||
roster_sending_owner_gm_id INTEGER,
|
||||
roster_broker_ts TEXT, -- received
|
||||
roster_broker_ts TEXT,
|
||||
roster_blob BLOB,
|
||||
roster_pending_version INTEGER,
|
||||
roster_pending_digest BLOB,
|
||||
roster_pending_msg_body BLOB,
|
||||
roster_pending_msg_chat_binding TEXT,
|
||||
roster_pending_msg_signatures BLOB,
|
||||
roster_pending_sending_owner_gm_id INTEGER,
|
||||
roster_pending_broker_ts TEXT, -- received
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
@@ -274,7 +282,9 @@ CREATE TABLE files(
|
||||
file_crypto_key BLOB,
|
||||
file_crypto_nonce BLOB,
|
||||
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE,
|
||||
redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE
|
||||
redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE,
|
||||
shared_msg_id BLOB,
|
||||
file_type TEXT NOT NULL DEFAULT 'normal'
|
||||
) STRICT;
|
||||
CREATE TABLE snd_files(
|
||||
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||
@@ -1313,6 +1323,10 @@ ON groups(
|
||||
relay_request_group_link
|
||||
)
|
||||
WHERE relay_request_group_link IS NOT NULL;
|
||||
CREATE INDEX idx_files_group_id_shared_msg_id ON files(
|
||||
group_id,
|
||||
shared_msg_id
|
||||
);
|
||||
CREATE TRIGGER on_group_members_insert_update_summary
|
||||
AFTER INSERT ON group_members
|
||||
FOR EACH ROW
|
||||
|
||||
@@ -973,6 +973,11 @@ newtype MemberKey = MemberKey C.PublicKeyEd25519
|
||||
deriving (Eq, Show)
|
||||
deriving newtype (StrEncoding)
|
||||
|
||||
-- Binary encoding for the roster blob; delegates to the Ed25519 key.
|
||||
instance Encoding MemberKey where
|
||||
smpEncode (MemberKey k) = smpEncode k
|
||||
smpP = MemberKey <$> smpP
|
||||
|
||||
instance FromJSON MemberKey where
|
||||
parseJSON = strParseJSON "MemberKey"
|
||||
|
||||
@@ -1494,11 +1499,38 @@ instance ToJSON InlineFileMode where
|
||||
toJSON = J.String . textEncode
|
||||
toEncoding = JE.text . textEncode
|
||||
|
||||
-- Discriminates ordinary chat files from the roster blob file, so the receive
|
||||
-- completion / cancel paths branch on the type rather than on chat_item_id (note
|
||||
-- folders and redirects also lack a chat item).
|
||||
data FileType = FTNormal | FTRoster
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance TextEncoding FileType where
|
||||
textEncode = \case
|
||||
FTNormal -> "normal"
|
||||
FTRoster -> "roster"
|
||||
textDecode = \case
|
||||
"normal" -> Just FTNormal
|
||||
"roster" -> Just FTRoster
|
||||
_ -> Nothing
|
||||
|
||||
instance FromField FileType where fromField = fromTextField_ textDecode
|
||||
|
||||
instance ToField FileType where toField = toField . textEncode
|
||||
|
||||
instance FromJSON FileType where
|
||||
parseJSON = textParseJSON "FileType"
|
||||
|
||||
instance ToJSON FileType where
|
||||
toJSON = J.String . textEncode
|
||||
toEncoding = JE.text . textEncode
|
||||
|
||||
data RcvFileTransfer = RcvFileTransfer
|
||||
{ fileId :: FileTransferId,
|
||||
xftpRcvFile :: Maybe XFTPRcvFile,
|
||||
fileInvitation :: FileInvitation,
|
||||
fileStatus :: RcvFileStatus,
|
||||
fileType :: FileType,
|
||||
rcvFileInline :: Maybe InlineFileMode,
|
||||
senderDisplayName :: ContactName,
|
||||
chunkSize :: Integer,
|
||||
|
||||
@@ -11,6 +11,7 @@ import qualified Data.ByteString.Char8 as B
|
||||
import Data.Text (Text)
|
||||
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
|
||||
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (dropPrefix, enumJSON)
|
||||
import Simplex.Messaging.Util ((<$?>))
|
||||
@@ -57,6 +58,12 @@ instance ToJSON GroupMemberRole where
|
||||
toJSON = textToJSON
|
||||
toEncoding = textToEncoding
|
||||
|
||||
-- Binary encoding for the roster blob; delegates to the canonical TextEncoding
|
||||
-- (same member/moderator/admin form JSON and the DB use). GRUnknown round-trips.
|
||||
instance Encoding GroupMemberRole where
|
||||
smpEncode = smpEncode . textEncode
|
||||
smpP = maybe (fail "bad GroupMemberRole") pure . textDecode =<< smpP
|
||||
|
||||
data GroupAcceptance = GAAccepted | GAPendingApproval | GAPendingReview deriving (Eq, Show)
|
||||
|
||||
instance StrEncoding GroupAcceptance where
|
||||
|
||||
@@ -293,6 +293,8 @@ chatGroupTests = do
|
||||
it "role transitions update the roster (mod <-> admin, admin -> non-roster)" testChannelRoleTransitionsUpdateRoster
|
||||
it "malicious relay cannot downgrade or re-key a roster-established moderator via XGrpMemNew" testChannelRelayCannotDowngradeRosterMember
|
||||
it "should add relay to channel with roster (relay caches roster before joinable)" testChannelAddRelayWithRoster
|
||||
it "roster blob spanning multiple chunks reassembles" testChannelRosterMultipartReassembly
|
||||
it "corrupted roster blob is rejected on digest mismatch" testChannelRosterDigestMismatchRejected
|
||||
describe "channel message operations" $ do
|
||||
it "should update channel message" testChannelMessageUpdate
|
||||
it "should delete channel message" testChannelMessageDelete
|
||||
@@ -10397,6 +10399,69 @@ testChannelAddRelayWithRoster ps =
|
||||
-- the new relay holds the roster (cath is moderator) before it serves joiners
|
||||
checkMemberRow dan "cath" (Just "moderator")
|
||||
|
||||
-- With a tiny file chunk size the owner-signed roster blob spans several chunks, exercising
|
||||
-- split + reassembly both owner->relay and relay->joiner. dan getting cath as moderator means
|
||||
-- the chunks were reassembled in order and the digest verified over the reassembled blob.
|
||||
testChannelRosterMultipartReassembly :: HasCallStack => TestParams -> IO ()
|
||||
testChannelRosterMultipartReassembly ps =
|
||||
withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob ->
|
||||
withNewTestChatCfgOpts ps cfg testOpts "cath" cathProfile $ \cath ->
|
||||
withNewTestChatCfgOpts ps cfg testOpts "dan" danProfile $ \dan -> do
|
||||
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
|
||||
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
|
||||
threadDelay 100000
|
||||
alice ##> "/mr #team cath moderator"
|
||||
alice <## "#team: you changed the role of cath to moderator (signed)"
|
||||
concurrentlyN_
|
||||
[ bob <## "#team: alice changed the role of cath from member to moderator (signed)",
|
||||
cath <## "#team: alice changed your role from member to moderator (signed)"
|
||||
]
|
||||
threadDelay 100000
|
||||
memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan
|
||||
dan <## "#team: alice changed the role of cath from member to moderator (signed)"
|
||||
threadDelay 100000
|
||||
checkMemberRow dan "cath" (Just "moderator")
|
||||
where
|
||||
cfg = testCfg {fileChunkSize = 30}
|
||||
|
||||
-- A malicious/garbled relay blob is rejected at the receiver: the owner-signed header carries
|
||||
-- the digest, so a blob that does not hash to it is discarded and the roster is not applied
|
||||
-- (the receiver's roster version does not advance to the corrupted roster's version).
|
||||
testChannelRosterDigestMismatchRejected :: HasCallStack => TestParams -> IO ()
|
||||
testChannelRosterDigestMismatchRejected ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
|
||||
withNewTestChat ps "cath" cathProfile $ \cath ->
|
||||
withNewTestChat ps "frank" frankProfile $ \frank -> do
|
||||
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
|
||||
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
|
||||
threadDelay 100000
|
||||
alice ##> "/mr #team cath moderator"
|
||||
alice <## "#team: you changed the role of cath to moderator (signed)"
|
||||
concurrentlyN_
|
||||
[ bob <## "#team: alice changed the role of cath from member to moderator (signed)",
|
||||
cath <## "#team: alice changed your role from member to moderator (signed)"
|
||||
]
|
||||
threadDelay 100000
|
||||
-- corrupt the relay's stored blob (same length, different content) so its digest no
|
||||
-- longer matches the signed header (DB-agnostic: read it, overwrite with zeroed bytes)
|
||||
withCCTransaction bob $ \db -> do
|
||||
rows <- DB.query_ db "SELECT roster_blob FROM groups WHERE roster_blob IS NOT NULL" :: IO [Only (Binary ByteString)]
|
||||
forM_ rows $ \(Only (Binary blob)) ->
|
||||
DB.execute db "UPDATE groups SET roster_blob = ? WHERE roster_blob IS NOT NULL" (Only (Binary (B.replicate (B.length blob) '\NUL')))
|
||||
-- frank joins; bob re-serves the valid header with the corrupted blob, frank rejects it
|
||||
threadDelay 100000
|
||||
memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank
|
||||
threadDelay 100000
|
||||
checkRosterVersionBelow frank 1
|
||||
where
|
||||
checkRosterVersionBelow :: HasCallStack => TestCC -> Int64 -> IO ()
|
||||
checkRosterVersionBelow cc target = do
|
||||
vs <- withCCTransaction cc $ \db ->
|
||||
DB.query_ db "SELECT roster_version FROM groups" :: IO [Only (Maybe Int64)]
|
||||
all (< target) [v | Only (Just v) <- vs] `shouldBe` True
|
||||
|
||||
testChannelRemoveRelay :: HasCallStack => TestParams -> IO ()
|
||||
testChannelRemoveRelay ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice ->
|
||||
|
||||
Reference in New Issue
Block a user