diff --git a/cassandra-schema.cql b/cassandra-schema.cql index 7d2e47e16e0..a5bd09a440e 100644 --- a/cassandra-schema.cql +++ b/cassandra-schema.cql @@ -1632,6 +1632,31 @@ CREATE TABLE galley_test.mls_group_member_client ( AND read_repair = 'BLOCKING' AND speculative_retry = '99p'; +CREATE TABLE galley_test.mls_history_client ( + group_id blob, + id uuid, + leaf_node_index int, + removal_pending boolean, + PRIMARY KEY (group_id, id) +) WITH CLUSTERING ORDER BY (id ASC) + AND additional_write_policy = '99p' + AND bloom_filter_fp_chance = 0.01 + AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'} + AND cdc = false + AND comment = '' + AND compaction = {'class': 'org.apache.cassandra.db.compaction.SizeTieredCompactionStrategy', 'max_threshold': '32', 'min_threshold': '4'} + AND compression = {'chunk_length_in_kb': '16', 'class': 'org.apache.cassandra.io.compress.LZ4Compressor'} + AND memtable = 'default' + AND crc_check_chance = 1.0 + AND default_time_to_live = 0 + AND extensions = {} + AND gc_grace_seconds = 864000 + AND max_index_interval = 2048 + AND memtable_flush_period_in_ms = 0 + AND min_index_interval = 128 + AND read_repair = 'BLOCKING' + AND speculative_retry = '99p'; + CREATE TABLE galley_test.mls_proposal_refs ( group_id blob, epoch bigint, diff --git a/changelog.d/2-features/WPB-20806 b/changelog.d/2-features/WPB-20806 new file mode 100644 index 00000000000..49689b99392 --- /dev/null +++ b/changelog.d/2-features/WPB-20806 @@ -0,0 +1 @@ +Enforced history client invariants for conversation history sharing: enabled requires exactly one history client, disabled requires none. diff --git a/integration/test/MLS/Util.hs b/integration/test/MLS/Util.hs index cdbd6fcee2c..ea4b4d7817d 100644 --- a/integration/test/MLS/Util.hs +++ b/integration/test/MLS/Util.hs @@ -22,6 +22,7 @@ module MLS.Util where import API.Brig import API.BrigCommon import API.Galley +import Control.Applicative import Control.Concurrent.Async hiding (link) import Control.Monad import Control.Monad.Catch @@ -46,6 +47,7 @@ import qualified Data.UUID as UUID import qualified Data.UUID.V4 as UUIDV4 import GHC.Stack import Notifications +import SetupHelpers (randomUUIDString) import System.Directory import System.Exit import System.FilePath @@ -69,6 +71,14 @@ mkClientIdentity u c = do cid2Str :: ClientIdentity -> String cid2Str cid = cid.user <> ":" <> cid.client <> "@" <> cid.domain +hid2Str :: String -> String +hid2Str hid = "history-client:" <> hid + +mem2Str :: GroupMember -> String +mem2Str = \case + RegularClient cid -> cid2Str cid + HistoryClient hid -> hid2Str hid + data MessagePackage = MessagePackage { sender :: ClientIdentity, convId :: ConvId, @@ -91,12 +101,21 @@ randomFileName = do (bd ) . UUID.toString <$> liftIO UUIDV4.nextRandom mlscli :: (HasCallStack) => Maybe ConvId -> Ciphersuite -> ClientIdentity -> [String] -> Maybe ByteString -> App ByteString -mlscli mConvId cs cid args mbstdin = do +mlscli mConvId cs cid = mlscliGroupMem mConvId cs (RegularClient cid) + +mlscliGroupMem :: (HasCallStack) => Maybe ConvId -> Ciphersuite -> GroupMember -> [String] -> Maybe ByteString -> App ByteString +mlscliGroupMem mConvId cs groupMem args mbstdin = do groupOut <- randomFileName let substOut = argSubst "" groupOut let scheme = csSignatureScheme cs - gs <- getClientGroupState cid + gs <- case groupMem of + RegularClient cid -> getClientGroupState cid + HistoryClient hid -> do + convId <- assertOne mConvId + state <- getMLSState + let keyStore = Map.findWithDefault mempty (convId, hid) state.historyClientState + pure $ ClientGroupState mempty keyStore BasicCredentialType substIn <- case flip Map.lookup gs.groups =<< mConvId of Nothing -> pure id @@ -106,7 +125,7 @@ mlscli mConvId cs cid args mbstdin = do store <- case Map.lookup scheme gs.keystore of Nothing -> do bd <- getBaseDir - liftIO (createDirectory (bd cid2Str cid)) + liftIO (createDirectory (bd mem2Str groupMem)) `catch` \e -> if (isAlreadyExistsError e) then pure () -- creates a file per signature scheme @@ -115,7 +134,7 @@ mlscli mConvId cs cid args mbstdin = do -- initialise new keystore path <- randomFileName ctype <- make gs.credType & asString - void $ runCli path ["init", "--ciphersuite", cs.code, "-t", ctype, cid2Str cid] Nothing + void $ runCli path ["init", "--ciphersuite", cs.code, "-t", ctype, mem2Str groupMem] Nothing pure path Just s -> toRandomFile s @@ -136,11 +155,15 @@ mlscli mConvId cs cid args mbstdin = do print =<< liftIO (prettierCallStack callStack) pure id _ -> pure id - setStore <- do - storeData <- liftIO (BS.readFile store) - pure $ \x -> x {keystore = Map.insert scheme storeData x.keystore} + storeData <- liftIO (BS.readFile store) + let setStore x = x {keystore = Map.insert scheme storeData x.keystore} - setClientGroupState cid (setGroup (setStore gs)) + case groupMem of + RegularClient cid -> setClientGroupState cid (setGroup (setStore gs)) + HistoryClient hid -> do + convId <- assertOne mConvId + modifyMLSState $ \s -> + s {historyClientState = Map.alter (Just . Map.insert scheme storeData . fromMaybe mempty) (convId, hid) s.historyClientState} pure out @@ -218,10 +241,19 @@ generateKeyPackage :: (HasCallStack) => ClientIdentity -> Ciphersuite -> App (By generateKeyPackage cid suite = do kp <- mlscli Nothing suite cid ["key-package", "create", "--ciphersuite", suite.code] Nothing ref <- B8.unpack . Base64.encode <$> mlscli Nothing suite cid ["key-package", "ref", "-"] (Just kp) - fp <- keyPackageFile cid ref + fp <- keyPackageFile (cid2Str cid) ref liftIO $ BS.writeFile fp kp pure (kp, ref) +generateHistoryClient :: (HasCallStack) => ConvId -> Ciphersuite -> App (ByteString, String, String) +generateHistoryClient convId suite = do + hid <- randomUUIDString + kp <- mlscliGroupMem (Just convId) suite (HistoryClient hid) ["key-package", "create", "--ciphersuite", suite.code] Nothing + ref <- B8.unpack . Base64.encode <$> mlscliGroupMem (Just convId) suite (HistoryClient hid) ["key-package", "ref", "-"] (Just kp) + fp <- keyPackageFile (hid2Str hid) ref + liftIO $ BS.writeFile fp kp + pure (kp, ref, hid) + -- | Create conversation and corresponding group. createNewGroup :: (HasCallStack) => Ciphersuite -> ClientIdentity -> App ConvId createNewGroup cs cid = createNewGroupWith cs cid defMLS @@ -348,11 +380,11 @@ resetClientGroup cs cid gid convId keys = do ] (Just removalKey) -keyPackageFile :: (HasCallStack) => ClientIdentity -> String -> App FilePath -keyPackageFile cid ref = do +keyPackageFile :: (HasCallStack) => String -> String -> App FilePath +keyPackageFile name ref = do let ref' = map urlSafe ref bd <- getBaseDir - pure $ bd cid2Str cid ref' + pure $ bd name ref' where urlSafe '+' = '-' urlSafe '/' = '_' @@ -383,7 +415,7 @@ createAddCommit cid convId users = do kps <- fmap concat . for users $ \user -> do bundle <- claimKeyPackages conv.ciphersuite cid user >>= getJSON 200 unbundleKeyPackages bundle - createAddCommitWithKeyPackages cid convId kps + createAddCommitWithKeyPackages cid convId kps Nothing withTempKeyPackageFile :: ByteString -> ContT a App FilePath withTempKeyPackageFile bs = do @@ -396,19 +428,30 @@ withTempKeyPackageFile bs = do liftIO $ BS.hPut h bs `finally` hClose h k fp +createAddCommitWithHistoryClient :: (HasCallStack) => ClientIdentity -> ConvId -> [Value] -> App (MessagePackage, String) +createAddCommitWithHistoryClient cid convId users = do + conv <- getMLSConv convId + kps <- fmap concat . for users $ \user -> do + bundle <- claimKeyPackages conv.ciphersuite cid user >>= getJSON 200 + unbundleKeyPackages bundle + (hckp, _, hid) <- generateHistoryClient convId conv.ciphersuite + mp <- createAddCommitWithKeyPackages cid convId kps (Just hckp) + pure (mp, hid) + createAddCommitWithKeyPackages :: (HasCallStack) => ClientIdentity -> ConvId -> [(ClientIdentity, ByteString)] -> + Maybe ByteString -> App MessagePackage -createAddCommitWithKeyPackages cid convId clientsAndKeyPackages = do +createAddCommitWithKeyPackages cid convId clientsAndKeyPackages hckp = do bd <- getBaseDir welcomeFile <- liftIO $ emptyTempFile bd "welcome" giFile <- liftIO $ emptyTempFile bd "gi" Just conv <- Map.lookup convId . (.convs) <$> getMLSState - commit <- runContT (traverse (withTempKeyPackageFile . snd) clientsAndKeyPackages) $ \kpFiles -> + commit <- runContT (traverse withTempKeyPackageFile (maybeToList hckp <> fmap snd clientsAndKeyPackages)) $ \kpFiles -> mlscli (Just convId) conv.ciphersuite @@ -452,7 +495,10 @@ createAddCommitWithKeyPackages cid convId clientsAndKeyPackages = do } createRemoveCommit :: (HasCallStack) => ClientIdentity -> ConvId -> [ClientIdentity] -> App MessagePackage -createRemoveCommit cid convId targets = do +createRemoveCommit cid convId targets = createRemoveCommitGroupMember cid convId (fmap RegularClient targets) + +createRemoveCommitGroupMember :: (HasCallStack) => ClientIdentity -> ConvId -> [GroupMember] -> App MessagePackage +createRemoveCommitGroupMember cid convId targets = do bd <- getBaseDir welcomeFile <- liftIO $ emptyTempFile bd "welcome" giFile <- liftIO $ emptyTempFile bd "gi" @@ -485,12 +531,16 @@ createRemoveCommit cid convId targets = do ) Nothing + let toRegular :: (Alternative f) => GroupMember -> f ClientIdentity + toRegular (RegularClient x) = pure x + toRegular (HistoryClient _) = empty + modifyMLSState $ \mls -> mls { convs = Map.adjust ( \oldConvState -> - oldConvState {membersToBeRemoved = Set.fromList targets} + oldConvState {membersToBeRemoved = Set.fromList (foldMap toRegular targets)} ) convId mls.convs @@ -884,7 +934,7 @@ showMessage cs cid msg = do bs <- mlscli Nothing cs cid ["show", "message", "-"] (Just msg) assertOne (Aeson.decode (BS.fromStrict bs)) -readGroupState :: (HasCallStack) => ByteString -> App [(ClientIdentity, Word32)] +readGroupState :: (HasCallStack) => ByteString -> App [(GroupMember, Word32)] readGroupState gs = do v :: Value <- assertJust "Could not decode group state" (Aeson.decode (BS.fromStrict gs)) lnodes <- v %. "group" %. "public_group" %. "treesync" %. "tree" %. "leaf_nodes" & asList @@ -897,10 +947,16 @@ readGroupState gs = do vecb <- lnode %. "payload" %. "credential" %. "credential" %. "Basic" %. "identity" %. "vec" vec <- asList vecb ws <- BS.pack <$> for vec (\x -> asIntegral @Word8 x) - [uc, domain] <- pure (C8.split '@' ws) - [uid, client] <- pure (C8.split ':' uc) - let cid = ClientIdentity (C8.unpack domain) (C8.unpack uid) (C8.unpack client) - pure (Just (cid, leafNodeIndex)) + let prefix = fromString "history-client:" + if (prefix `BS.isPrefixOf` ws) + then do + let hid = BS.drop (BS.length prefix) ws + pure $ Just (HistoryClient $ C8.unpack hid, leafNodeIndex) + else do + [uc, domain] <- pure (C8.split '@' ws) + [uid, client] <- pure (C8.split ':' uc) + let cid = RegularClient $ ClientIdentity (C8.unpack domain) (C8.unpack uid) (C8.unpack client) + pure (Just (cid, leafNodeIndex)) Nothing -> pure Nothing @@ -1062,3 +1118,8 @@ resetMLSConversation cid conv = do keys <- getMLSPublicKeys cid >>= getJSON 200 resetClientGroup mlsConv'.ciphersuite cid mlsConv'.groupId convId' keys pure conv' + +withMLSStateReset :: App a -> App a +withMLSStateReset f = do + mlsState <- getMLSState + f <* modifyMLSState (const mlsState) diff --git a/integration/test/Test/MLS.hs b/integration/test/Test/MLS.hs index 567168f1200..ca8dc088b64 100644 --- a/integration/test/Test/MLS.hs +++ b/integration/test/Test/MLS.hs @@ -311,7 +311,7 @@ testMixedProtocolAddPartialClients secondDomain = do bundle <- claimKeyPackages def alice1 bob >>= getJSON 200 kps <- unbundleKeyPackages bundle kp1 <- assertOne (filter ((== bob1) . fst) kps) - mp <- createAddCommitWithKeyPackages alice1 convId [kp1] + mp <- createAddCommitWithKeyPackages alice1 convId [kp1] Nothing void $ sendAndConsumeCommitBundleWithProtocol MLSProtocolMixed mp -- this tests that bob's backend has a mapping of group id to the remote conv @@ -320,7 +320,7 @@ testMixedProtocolAddPartialClients secondDomain = do bundle <- claimKeyPackages def bob1 bob >>= getJSON 200 kps <- unbundleKeyPackages bundle kp2 <- assertOne (filter ((== bob2) . fst) kps) - mp <- createAddCommitWithKeyPackages bob1 convId [kp2] + mp <- createAddCommitWithKeyPackages bob1 convId [kp2] Nothing void $ postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 201 testMixedProtocolRemovePartialClients :: (HasCallStack) => Domain -> App () @@ -590,7 +590,7 @@ testFirstCommitAllowsPartialAdds = do kps <- unbundleKeyPackages bundle -- first commit only adds kp for alice2 (not alice2 and alice3) - mp <- createAddCommitWithKeyPackages alice1 convId (filter ((== alice2) . fst) kps) + mp <- createAddCommitWithKeyPackages alice1 convId (filter ((== alice2) . fst) kps) Nothing bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do resp.status `shouldMatchInt` 409 resp.json %. "label" `shouldMatch` "mls-client-mismatch" @@ -618,7 +618,7 @@ testAddUserPartial = do kps <- fmap concat . for [bob, charlie] $ \user -> do bundle <- claimKeyPackages def alice1 user >>= getJSON 200 unbundleKeyPackages bundle - mp <- createAddCommitWithKeyPackages alice1 convId kps + mp <- createAddCommitWithKeyPackages alice1 convId kps Nothing -- before alice can commit, bob3 uploads a key package void $ uploadNewKeyPackage def bob3 @@ -970,7 +970,7 @@ testInternalCommitDuplicateClient = do -- We cannot upload the new key package at this point, because the -- signature key won't match. However, alice1 can still use it to craft an -- add proposal. - mp <- createAddCommitWithKeyPackages alice1 convId [(alice2, kp)] + mp <- createAddCommitWithKeyPackages alice1 convId [(alice2, kp)] Nothing bindResponse (postMLSCommitBundle alice1 (mkBundle mp)) $ \resp -> do resp.status `shouldMatchInt` 400 resp.json %. "label" `shouldMatch` "mls-protocol-error" @@ -1005,7 +1005,7 @@ testInternalCommitWrongSignatureKey = do setClientGroupState alice2 def (kp, _) <- generateKeyPackage alice2 def - mp <- createAddCommitWithKeyPackages alice1 convId [(alice2, kp)] + mp <- createAddCommitWithKeyPackages alice1 convId [(alice2, kp)] Nothing bindResponse (postMLSCommitBundle alice1 (mkBundle mp)) $ \resp -> do resp.status `shouldMatchInt` 403 resp.json %. "label" `shouldMatch` "mls-identity-mismatch" diff --git a/integration/test/Test/MLS/History.hs b/integration/test/Test/MLS/History.hs index 52c94b248a5..8e87d893400 100644 --- a/integration/test/Test/MLS/History.hs +++ b/integration/test/Test/MLS/History.hs @@ -19,6 +19,7 @@ module Test.MLS.History where import API.Galley import qualified API.GalleyInternal as I +import qualified Data.Aeson as A import qualified Data.ByteString.Base64 as Base64 import qualified Data.Text.Encoding as T import MLS.Util @@ -137,6 +138,122 @@ testSetHistory = do conv <- getConversation alice convId >>= getJSON 200 conv %. "history" `shouldMatch` history +testHistoryConflicts :: (HasCallStack) => Domain -> App () +testHistoryConflicts domain = do + (alice, tid, _) <- createTeam OwnDomain 1 + mems@[bob, charlie, dorothy, emily] <- replicateM 4 $ randomUser domain def + for_ mems $ connectTwoUsers alice + + I.setTeamFeatureLockStatus alice tid "channels" "unlocked" + setTeamFeatureConfig alice tid "channels" channelsConfig >>= assertSuccess + + clients@(alice1 : bob1 : _) <- traverse (createMLSClient def) $ alice : mems + for_ clients $ uploadNewKeyPackage def + convId <- createNewGroupWith def alice1 defMLS {team = Just tid, groupConvType = Just "channel"} + + -- adding an empty commit to be able to test application message rejection + void $ createPendingProposalCommit convId alice1 >>= sendAndConsumeCommitBundle + + -- bob is added to the conversation as a wire-member (not a conversation admin) + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle + + getConversation alice convId `bindResponse` \res -> do + res.status `shouldMatchInt` 200 + other <- res.json %. "members.others" >>= asList >>= assertOne + other %. "id" `shouldMatch` (bob %. "id") + other %. "conversation_role" `shouldMatch` "wire_member" + + -- a history client cannot be added if shared history is disabled + assertAddHistoryClientConflict convId alice1 + + -- SHARED HISTORY ENABLED + enableHistorySharing convId alice + + -- application message and add commit are rejected + assertApplicationMessageFailure convId alice1 + assertAddCommitIsRejected convId alice1 [charlie] + + -- HISTORY CLIENT ADDED + hid <- do + -- this verifies that history clients can be added by non-conversation admins including federated users + (mp, hid) <- createAddCommitWithHistoryClient bob1 convId [] + void $ sendAndConsumeCommitBundle mp + pure hid + + -- it is not possible to add more than 1 history client or to remove it + assertAddHistoryClientDuplication convId alice1 + assertRemoveHistoryClientFailure convId alice1 hid + + -- application message and add commits are accepted + void $ createApplicationMessage convId alice1 "hello" >>= sendAndConsumeMessage + void $ createAddCommit alice1 convId [charlie] >>= sendAndConsumeCommitBundle + + -- SHARED HISTORY DISABLED + disableHistorySharing convId alice + + -- application message and add commit, as well as history client requests are rejected + assertApplicationMessageFailure convId alice1 + assertAddCommitIsRejected convId alice1 [dorothy] + assertAddHistoryClientDuplication convId alice1 + + -- HISTORY CLIENT REMOVED + void $ createRemoveCommitGroupMember bob1 convId [HistoryClient hid] >>= sendAndConsumeCommitBundle + + -- application message and add commits are accepted + void $ createApplicationMessage convId alice1 "hello" >>= sendAndConsumeMessage + void $ createAddCommit alice1 convId [emily] >>= sendAndConsumeCommitBundle + + -- a history client cannot be added if shared history is disabled + assertAddHistoryClientConflict convId alice1 + where + assertAddHistoryClientConflict :: (HasCallStack) => ConvId -> ClientIdentity -> App () + assertAddHistoryClientConflict = assertAddHistoryClientFailure 400 "mls-history-client-conflict" + + assertAddHistoryClientDuplication :: (HasCallStack) => ConvId -> ClientIdentity -> App () + assertAddHistoryClientDuplication = assertAddHistoryClientFailure 400 "mls-history-client-duplication" + + assertAddHistoryClientFailure :: (HasCallStack) => Int -> String -> ConvId -> ClientIdentity -> App () + assertAddHistoryClientFailure status label convId user = + withMLSStateReset $ do + mp <- fst <$> createAddCommitWithHistoryClient user convId [] + postMLSCommitBundle mp.sender (mkBundle mp) `bindResponse` \resp -> do + resp.status `shouldMatchInt` status + resp.json %. "label" `shouldMatch` label + + assertRemoveHistoryClientFailure :: (HasCallStack) => ConvId -> ClientIdentity -> String -> App () + assertRemoveHistoryClientFailure convId user hid = + withMLSStateReset $ do + mp <- createRemoveCommitGroupMember user convId [HistoryClient hid] + postMLSCommitBundle mp.sender (mkBundle mp) `bindResponse` \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "mls-history-client-conflict" + + assertAddCommitIsRejected :: (HasCallStack) => ConvId -> ClientIdentity -> [Value] -> App () + assertAddCommitIsRejected convId user users = + withMLSStateReset $ do + mp <- createAddCommit user convId users + postMLSCommitBundle mp.sender (mkBundle mp) `bindResponse` \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "mls-history-client-conflict" + + assertApplicationMessageFailure :: (HasCallStack) => ConvId -> ClientIdentity -> App () + assertApplicationMessageFailure convId user = do + mp <- createApplicationMessage convId user "hello" + postMLSMessage mp.sender mp.message `bindResponse` \res -> do + res.status `shouldMatchInt` 400 + res.json %. "label" `shouldMatch` "mls-history-client-conflict" + + enableHistorySharing :: (HasCallStack) => ConvId -> Value -> App () + enableHistorySharing convId user = do + let history = object ["depth" .= "infinite"] + bindResponse (updateHistory user convId history) $ \resp -> do + resp.status `shouldMatchInt` 200 + + disableHistorySharing :: (HasCallStack) => ConvId -> Value -> App () + disableHistorySharing convId user = do + bindResponse (updateHistory user convId A.Null) $ \resp -> do + resp.status `shouldMatchInt` 200 + channelsConfig :: Value channelsConfig = object diff --git a/integration/test/Test/Migration/Conversation.hs b/integration/test/Test/Migration/Conversation.hs index dfb80e91a7a..5b99385ec1e 100644 --- a/integration/test/Test/Migration/Conversation.hs +++ b/integration/test/Test/Migration/Conversation.hs @@ -13,6 +13,7 @@ module Test.Migration.Conversation where import API.Galley +import qualified API.GalleyInternal as I import Control.Applicative import Control.Monad.Codensity import Control.Monad.Reader @@ -23,6 +24,7 @@ import GHC.Stack import MLS.Util import Notifications import SetupHelpers hiding (deleteUser) +import Test.MLS.History (channelsConfig) import Test.Migration.Util import Testlib.Prelude import Testlib.ResourcePool @@ -39,7 +41,7 @@ testMigrationToPostgresMLS = do runCodensity (acquireResources 1 resourcePool) $ \[migratingBackend] -> do let domainM = migratingBackend.berDomain - (mel, melC, mark, markC, mia, miaC, miaTid, domainAConvs, domainBConvs, domainMConvs, otherMelConvs) <- runCodensity (startDynamicBackend migratingBackend phase1Overrides) $ \_ -> do + (mel, melC, mark, markC, mia, miaC, miaTid, domainAConvs, domainBConvs, domainMConvs, sharedHistoryConv, otherMelConvs) <- runCodensity (startDynamicBackend migratingBackend phase1Overrides) $ \_ -> do [mel, mark] <- createUsers [domainM, domainM] (mia, miaTid, _) <- createTeam domainM 1 [melC, markC, miaC] <- traverse (createMLSClient def) [mel, mark, mia] @@ -48,8 +50,11 @@ testMigrationToPostgresMLS = do domainAConvs <- createTestConvs aliceC aliceTid melC markC [] domainBConvs <- createTestConvs bobC bobTid melC markC [] - domainMConvs <- createTestConvs miaC miaTid melC markC [] - pure (mel, melC, mark, markC, mia, miaC, miaTid, domainAConvs, domainBConvs, domainMConvs, otherMelConvs) + sharedHistoryConv <- createSharedHistoryChannel mia miaTid miaC melC + domainMConvs <- do + domainMConvs <- createTestConvs miaC miaTid melC markC [] + pure $ domainMConvs {unmodifiedConvs = sharedHistoryConv : domainMConvs.unmodifiedConvs} + pure (mel, melC, mark, markC, mia, miaC, miaTid, domainAConvs, domainBConvs, domainMConvs, sharedHistoryConv, otherMelConvs) newConvsRef <- newIORef [] addUsersToFailureContext [("alice", alice), ("bob", bob), ("mel", mel), ("mark", mark), ("mia", mia)] @@ -83,6 +88,9 @@ testMigrationToPostgresMLS = do when (phase == 3) $ do waitForMigration domainM convMigrationFinishedCounterName waitForMigration domainM userMigrationFinishedCounterName + + when (phase == 5) + $ assertSharedHistorySuccess sharedHistoryConv melC runPhase 1 runPhase 2 runPhase 3 @@ -108,6 +116,29 @@ testMigrationToPostgresMLS = do void $ createAddCommit creatorC conv ((.qualifiedUserId) <$> membersC) >>= sendAndConsumeCommitBundle pure conv + createSharedHistoryChannel :: (HasCallStack) => Value -> String -> ClientIdentity -> ClientIdentity -> App ConvId + createSharedHistoryChannel owner tid ownerC memberC = do + I.setTeamFeatureLockStatus owner tid "channels" "unlocked" + setTeamFeatureConfig owner tid "channels" channelsConfig >>= assertSuccess + conv <- createNewGroupWith def ownerC defMLS {team = Just tid, groupConvType = Just "channel"} + void $ createPendingProposalCommit conv ownerC >>= sendAndConsumeCommitBundle + bindResponse (updateHistory owner conv history) $ \resp -> + resp.status `shouldMatchInt` 200 + void $ uploadNewKeyPackage def memberC + (mp, _) <- createAddCommitWithHistoryClient ownerC conv [memberC.qualifiedUserId] + void $ sendAndConsumeCommitBundle mp + pure conv + where + history = object ["depth" .= "infinite"] + + assertSharedHistorySuccess :: (HasCallStack) => ConvId -> ClientIdentity -> App () + assertSharedHistorySuccess conv memberC = do + convJson <- getConversation memberC conv >>= getJSON 200 + convJson %. "history" `shouldMatch` history + void $ createApplicationMessage conv memberC "hello in chat with shared history" >>= sendAndConsumeMessage + where + history = object ["depth" .= "infinite"] + forPhase :: App a -> App (IntMap [a]) forPhase action = fmap IntMap.fromList . for [1 .. 5] $ \phase -> do diff --git a/integration/test/Testlib/Env.hs b/integration/test/Testlib/Env.hs index 22e13ef43d9..464b4179d24 100644 --- a/integration/test/Testlib/Env.hs +++ b/integration/test/Testlib/Env.hs @@ -216,7 +216,8 @@ mkMLSState = Codensity $ \k -> MLSState { baseDir = tmp, convs = mempty, - clientGroupState = mempty + clientGroupState = mempty, + historyClientState = mempty } getMLSConv :: (HasCallStack) => ConvId -> App MLSConv diff --git a/integration/test/Testlib/Types.hs b/integration/test/Testlib/Types.hs index 9f1d10c13bd..5b9de9696f4 100644 --- a/integration/test/Testlib/Types.hs +++ b/integration/test/Testlib/Types.hs @@ -299,6 +299,9 @@ data ClientIdentity = ClientIdentity instance HasField "qualifiedUserId" ClientIdentity Aeson.Value where getField cid = object [fromString "id" .= cid.user, fromString "domain" .= cid.domain] +data GroupMember = HistoryClient String | RegularClient ClientIdentity + deriving (Show, Eq, Ord, Generic) + newtype Ciphersuite = Ciphersuite {code :: String} deriving (Eq, Ord, Show, Generic) @@ -358,7 +361,8 @@ instance ToJSON ConvId where data MLSState = MLSState { baseDir :: FilePath, convs :: Map ConvId MLSConv, - clientGroupState :: Map ClientIdentity ClientGroupState + clientGroupState :: Map ClientIdentity ClientGroupState, + historyClientState :: Map (ConvId, String) (Map String ByteString) } deriving (Show) diff --git a/libs/types-common/src/Data/Id.hs b/libs/types-common/src/Data/Id.hs index 87b51358ae7..a1c0aedf911 100644 --- a/libs/types-common/src/Data/Id.hs +++ b/libs/types-common/src/Data/Id.hs @@ -58,6 +58,7 @@ module Data.Id OAuthRefreshTokenId, ChallengeId, MeetingId, + HistoryClientId, -- * Utils uuidSchema, @@ -116,6 +117,7 @@ data IdTag | Challenge | Job | Meeting + | HistoryClient idTagName :: IdTag -> Text idTagName Asset = "Asset" @@ -132,6 +134,7 @@ idTagName OAuthRefreshToken = "OAuthRefreshToken" idTagName Challenge = "Challenge" idTagName Job = "Job" idTagName Meeting = "Meeting" +idTagName HistoryClient = "HistoryClient" class KnownIdTag (t :: IdTag) where idTagValue :: IdTag @@ -162,6 +165,8 @@ instance KnownIdTag 'Job where idTagValue = Job instance KnownIdTag 'Meeting where idTagValue = Meeting +instance KnownIdTag 'HistoryClient where idTagValue = HistoryClient + type AssetId = Id 'Asset type InvitationId = Id 'Invitation @@ -192,6 +197,8 @@ type JobId = Id 'Job type MeetingId = Id 'Meeting +type HistoryClientId = Id 'HistoryClient + -- Id ------------------------------------------------------------------------- data NoId = NoId deriving (Eq, Show, Generic) diff --git a/libs/wire-api/src/Wire/API/Error/Galley.hs b/libs/wire-api/src/Wire/API/Error/Galley.hs index 7c57f12f1ab..abbdea677c6 100644 --- a/libs/wire-api/src/Wire/API/Error/Galley.hs +++ b/libs/wire-api/src/Wire/API/Error/Galley.hs @@ -179,6 +179,8 @@ data GalleyError | MLSReadReceiptsNotAllowed | MLSInvalidLeafNodeSignature | MeetingNotFound + | MLSHistoryClientConflict + | MLSHistoryClientDuplication deriving (Show, Eq, Generic) deriving (FromJSON, ToJSON) via (CustomEncoded GalleyError) @@ -379,6 +381,10 @@ type instance MapError 'MLSReadReceiptsNotAllowed = 'StaticError 403 "mls-receip type instance MapError 'MLSInvalidLeafNodeSignature = 'StaticError 400 "mls-invalid-leaf-node-signature" "Invalid leaf node signature" +type instance MapError 'MLSHistoryClientConflict = 'StaticError 400 "mls-history-client-conflict" "History sharing settings of the conversation are conflicting with this request" + +type instance MapError 'MLSHistoryClientDuplication = 'StaticError 400 "mls-history-client-duplication" "An MLS group can have at most one history client" + -------------------------------------------------------------------------------- -- Meeting errors @@ -631,7 +637,7 @@ data GroupInfoDiagnostics = GroupInfoDiagnostics { commit :: ByteString, groupInfo :: ByteString, groupId :: GroupId, - clients :: [(Int, ClientIdentity)], + clients :: [(Int, GroupMember)], convId :: ConvOrSubConvId, domain :: Domain } @@ -649,7 +655,7 @@ instance APIError GroupInfoDiagnostics where headers = [] } -indexedClientSchema :: ValueSchema NamedSwaggerDoc (Int, ClientIdentity) +indexedClientSchema :: ValueSchema NamedSwaggerDoc (Int, GroupMember) indexedClientSchema = object $ (,) diff --git a/libs/wire-api/src/Wire/API/MLS/Credential.hs b/libs/wire-api/src/Wire/API/MLS/Credential.hs index 607729eb902..c7ece10f0ef 100644 --- a/libs/wire-api/src/Wire/API/MLS/Credential.hs +++ b/libs/wire-api/src/Wire/API/MLS/Credential.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE TemplateHaskell #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -17,7 +19,7 @@ module Wire.API.MLS.Credential where -import Control.Lens ((?~)) +import Control.Lens (makePrisms, (?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Binary import Data.Binary.Get @@ -120,10 +122,12 @@ instance ToHttpApiData ClientIdentity where toHeader = encodeMLS' toUrlPiece = T.decodeUtf8 . encodeMLS' +parseId :: Get (Id a) +parseId = maybe (fail "Invalid UUID") (pure . Id) . fromASCIIBytes =<< getByteString 36 + instance ParseMLS ClientIdentity where parseMLS = do - uid <- - maybe (fail "Invalid UUID") (pure . Id) . fromASCIIBytes =<< getByteString 36 + uid <- parseId char ':' cid <- ClientId <$> hexadecimal char '@' @@ -131,6 +135,21 @@ instance ParseMLS ClientIdentity where either fail pure . (mkDomain . T.pack) =<< many' anyChar pure $ ClientIdentity dom uid cid +data GroupMember = RegularClient ClientIdentity | HistoryClient HistoryClientId + deriving (Eq, Show) + +isHistoryClient :: GroupMember -> Bool +isHistoryClient (HistoryClient _) = True +isHistoryClient (RegularClient _) = False + +parseHistoryClient :: Get HistoryClientId +parseHistoryClient = string "history-client:" *> parseId + +instance ParseMLS GroupMember where + parseMLS = + (HistoryClient <$> parseHistoryClient) + <|> (RegularClient <$> parseMLS) + -- format of the x509 client identity: {userid}%21{deviceid}@{host} parseX509ClientIdentity :: Get ClientIdentity parseX509ClientIdentity = do @@ -154,3 +173,11 @@ instance SerialiseMLS ClientIdentity where mkClientIdentity :: Qualified UserId -> ClientId -> ClientIdentity mkClientIdentity (Qualified uid domain) = ClientIdentity domain uid + +makePrisms ''GroupMember + +instance ToSchema GroupMember where + schema = + named "GroupMember" $ + tag _RegularClient (unnamed schema) + <> tag _HistoryClient (unnamed schema) diff --git a/libs/wire-api/src/Wire/API/MLS/KeyPackage.hs b/libs/wire-api/src/Wire/API/MLS/KeyPackage.hs index 1f3e97d1098..741ecf3c571 100644 --- a/libs/wire-api/src/Wire/API/MLS/KeyPackage.hs +++ b/libs/wire-api/src/Wire/API/MLS/KeyPackage.hs @@ -233,7 +233,7 @@ instance HasField "extensions" KeyPackage [Extension] where instance HasField "leafNode" KeyPackage LeafNode where getField = (.tbs.value.leafNode) -credentialIdentityAndKey :: Credential -> Either Text (ClientIdentity, Maybe X509.PubKey) +credentialIdentityAndKey :: Credential -> Either Text (GroupMember, Maybe X509.PubKey) credentialIdentityAndKey (BasicCredential i) = (,) <$> decodeMLS' i <*> pure Nothing credentialIdentityAndKey (X509Credential certs) = do bs <- case certs of @@ -244,9 +244,9 @@ credentialIdentityAndKey (X509Credential certs) = do X509.decodeSignedCertificate bs -- FUTUREWORK: verify signature let cert = X509.getCertificate signed - certificateIdentityAndKey cert + first RegularClient <$> certificateIdentityAndKey cert -keyPackageIdentity :: KeyPackage -> Either Text ClientIdentity +keyPackageIdentity :: KeyPackage -> Either Text GroupMember keyPackageIdentity kp = fst <$> credentialIdentityAndKey kp.leafNode.credential certificateIdentityAndKey :: X509.Certificate -> Either Text (ClientIdentity, Maybe X509.PubKey) diff --git a/libs/wire-api/src/Wire/API/MLS/Validation.hs b/libs/wire-api/src/Wire/API/MLS/Validation.hs index 6a37f1d0cb8..66bb5e0863b 100644 --- a/libs/wire-api/src/Wire/API/MLS/Validation.hs +++ b/libs/wire-api/src/Wire/API/MLS/Validation.hs @@ -39,7 +39,7 @@ import Wire.API.MLS.Serialisation import Wire.API.MLS.Validation.Error validateKeyPackage :: - Maybe ClientIdentity -> + Maybe GroupMember -> KeyPackage -> Either ValidationError (CipherSuiteTag, Lifetime) validateKeyPackage mIdentity kp = do @@ -79,7 +79,7 @@ validateKeyPackage mIdentity kp = do validateLeafNode :: CipherSuiteTag -> - Maybe ClientIdentity -> + Maybe GroupMember -> LeafNodeTBSExtra -> LeafNode -> Either ValidationError () @@ -99,7 +99,12 @@ validateLeafNode cs mIdentity extra leafNode = do validateSource extra.tag leafNode.source validateCapabilities (credentialTag leafNode.credential) leafNode.capabilities -validateCredential :: CipherSuiteTag -> ByteString -> Maybe ClientIdentity -> Credential -> Either ValidationError () +validateCredential :: + CipherSuiteTag -> + ByteString -> + Maybe GroupMember -> + Credential -> + Either ValidationError () validateCredential cs pkey mIdentity cred = do -- FUTUREWORK: check signature in the case of an x509 credential (identity, mkey) <- diff --git a/libs/wire-api/test/unit/Test/Wire/API/MLS.hs b/libs/wire-api/test/unit/Test/Wire/API/MLS.hs index b6fcf5d945f..0eb770c2d93 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/MLS.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/MLS.hs @@ -89,7 +89,7 @@ testParseKeyPackage = do case keyPackageIdentity kp of Left err -> assertFailure $ "Failed to parse identity: " <> T.unpack err - Right identity -> identity @?= alice + Right identity -> identity @?= RegularClient alice testParseKeyPackageWithCapabilities :: IO () testParseKeyPackageWithCapabilities = do diff --git a/libs/wire-subsystems/postgres-migrations/20260507162106-history_client.sql b/libs/wire-subsystems/postgres-migrations/20260507162106-history_client.sql new file mode 100644 index 00000000000..a459c6ce54c --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260507162106-history_client.sql @@ -0,0 +1,7 @@ +CREATE TABLE mls_history_client ( + group_id bytea NOT NULL, + id uuid NOT NULL, + leaf_node_index integer NOT NULL, + removal_pending boolean NOT NULL, + PRIMARY KEY (group_id, id) +); diff --git a/libs/wire-subsystems/src/Wire/ConversationStore.hs b/libs/wire-subsystems/src/Wire/ConversationStore.hs index 466dec4b230..5e83d26bf65 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore.hs @@ -120,10 +120,14 @@ data ConversationStore m a where DeleteMembers :: ConvId -> UserList UserId -> ConversationStore m () DeleteMembersInRemoteConversation :: Remote ConvId -> [UserId] -> ConversationStore m () AddMLSClients :: GroupId -> Qualified UserId -> Set (ClientId, LeafIndex) -> ConversationStore m () + AddHistoryClient :: GroupId -> HistoryClientId -> LeafIndex -> ConversationStore m () + RemoveHistoryClient :: GroupId -> HistoryClientId -> ConversationStore m () + RemoveAllHistoryClients :: GroupId -> ConversationStore m () PlanClientRemoval :: (Foldable f) => GroupId -> f ClientIdentity -> ConversationStore m () RemoveMLSClients :: GroupId -> Qualified UserId -> Set ClientId -> ConversationStore m () RemoveAllMLSClients :: GroupId -> ConversationStore m () LookupMLSClients :: GroupId -> ConversationStore m (ClientMap LeafIndex) + LookupHistoryClients :: GroupId -> ConversationStore m [(HistoryClientId, Int32, Bool)] LookupMLSClientLeafIndices :: GroupId -> ConversationStore m (ClientMap LeafIndex, IndexMap) -- SUB CONVERSATION OPERATIONS UpsertSubConversation :: ConvId -> SubConvId -> GroupId -> ConversationStore m SubConversation diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs index bc36f8de6bc..12212a2949b 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs @@ -27,7 +27,6 @@ import Cassandra import Cassandra qualified as Cql import Cassandra.Settings import Cassandra.Util -import Control.Arrow import Control.Error.Util hiding (hoistMaybe) import Control.Lens import Control.Monad.Trans.Maybe @@ -730,6 +729,24 @@ addMLSClients groupId (Qualified usr domain) cs = retry x5 . batch $ do for_ cs $ \(c, idx) -> addPrepQuery Cql.addMLSClient (groupId, domain, usr, c, fromIntegral idx) +addHistoryClient :: GroupId -> HistoryClientId -> LeafIndex -> Client () +addHistoryClient groupId hid idx = + retry x5 . batch $ do + setType BatchLogged + setConsistency LocalQuorum + addPrepQuery Cql.addHistoryClient (groupId, hid, fromIntegral idx) + +removeHistoryClient :: GroupId -> HistoryClientId -> Client () +removeHistoryClient gid hid = + retry x5 . batch $ do + setType BatchLogged + setConsistency LocalQuorum + addPrepQuery Cql.removeHistoryClient (gid, hid) + +removeAllHistoryClients :: GroupId -> Client () +removeAllHistoryClients groupId = do + retry x5 $ write Cql.removeAllHistoryClients (params LocalQuorum (Identity groupId)) + planMLSClientRemoval :: (Foldable f) => GroupId -> f ClientIdentity -> Client () planMLSClientRemoval groupId cids = retry x5 . batch $ do @@ -767,8 +784,13 @@ addBotMember s bot cnv = do lookupMLSClientLeafIndices :: GroupId -> Client (ClientMap LeafIndex, IndexMap) lookupMLSClientLeafIndices groupId = do - entries <- retry x5 (query Cql.lookupMLSClients (params LocalQuorum (Identity groupId))) - pure $ (mkClientMap &&& mkIndexMap) entries + mlsClients <- retry x5 (query Cql.lookupMLSClients (params LocalQuorum (Identity groupId))) + hClients <- lookupHistoryClients groupId + pure $ (mkClientMap mlsClients, mkIndexMapFromParts mlsClients hClients) + +lookupHistoryClients :: GroupId -> Client [(HistoryClientId, Int32, Bool)] +lookupHistoryClients groupId = + retry x5 (query Cql.lookupHistoryClients (params LocalQuorum (Identity groupId))) lookupMLSClients :: GroupId -> Client (ClientMap LeafIndex) lookupMLSClients = fmap fst . lookupMLSClientLeafIndices @@ -1026,6 +1048,15 @@ interpretConversationStoreToCassandra client = interpret $ \case AddMLSClients lcnv quid cs -> do logEffect "ConversationStore.AddMLSClients" embedClient client $ addMLSClients lcnv quid cs + AddHistoryClient groupId hid idx -> do + logEffect "ConversationStore.AddHistoryClient" + embedClient client $ addHistoryClient groupId hid idx + RemoveHistoryClient groupId hid -> do + logEffect "ConversationStore.RemoveHistoryClient" + embedClient client $ removeHistoryClient groupId hid + RemoveAllHistoryClients groupId -> do + logEffect "ConversationStore.RemoveAllHistoryClients" + embedClient client $ removeAllHistoryClients groupId PlanClientRemoval lcnv cids -> do logEffect "ConversationStore.PlanClientRemoval" embedClient client $ planMLSClientRemoval lcnv cids @@ -1038,6 +1069,9 @@ interpretConversationStoreToCassandra client = interpret $ \case LookupMLSClients lcnv -> do logEffect "ConversationStore.LookupMLSClients" embedClient client $ lookupMLSClients lcnv + LookupHistoryClients gid -> do + logEffect "ConversationStore.LookupHistoryClients" + embedClient client $ lookupHistoryClients gid LookupMLSClientLeafIndices lcnv -> do logEffect "ConversationStore.LookupMLSClientLeafIndices" embedClient client $ lookupMLSClientLeafIndices lcnv @@ -1404,6 +1438,26 @@ interpretConversationStoreToCassandraAndPostgres client = interpret $ \case isConvInPostgres cid >>= \case False -> embedClient client $ addMLSClients groupId quid cs True -> interpretConversationStoreToPostgres (ConvStore.addMLSClients groupId quid cs) + AddHistoryClient groupId hid idx -> do + logEffect "ConversationStore.AddHistoryClient" + cid <- groupIdToConvId groupId + withMigrationLockAndCleanup client LockShared (Left cid) $ + isConvInPostgres cid >>= \case + False -> embedClient client $ addHistoryClient groupId hid idx + True -> interpretConversationStoreToPostgres (ConvStore.addHistoryClient groupId hid idx) + RemoveHistoryClient groupId hid -> do + logEffect "ConversationStore.RemoveHistoryClient" + cid <- groupIdToConvId groupId + withMigrationLockAndCleanup client LockShared (Left cid) $ + isConvInPostgres cid >>= \case + False -> embedClient client $ removeHistoryClient groupId hid + True -> interpretConversationStoreToPostgres (ConvStore.removeHistoryClient groupId hid) + RemoveAllHistoryClients gid -> do + logEffect "ConversationStore.RemoveAllHistoryClients" + cid <- groupIdToConvId gid + withMigrationLockAndCleanup client LockShared (Left cid) $ do + embedClient client $ removeAllHistoryClients gid + interpretConversationStoreToPostgres (ConvStore.removeAllHistoryClients gid) PlanClientRemoval gid clients -> do logEffect "ConversationStore.PlanClientRemoval" cid <- groupIdToConvId gid @@ -1430,6 +1484,13 @@ interpretConversationStoreToCassandraAndPostgres client = interpret $ \case isConvInPostgres cid >>= \case False -> embedClient client $ lookupMLSClients gid True -> interpretConversationStoreToPostgres (ConvStore.lookupMLSClients gid) + LookupHistoryClients gid -> do + logEffect "ConversationStore.LookupHistoryClients" + cid <- groupIdToConvId gid + withMigrationLockAndCleanup client LockShared (Left cid) $ + isConvInPostgres cid >>= \case + False -> embedClient client $ lookupHistoryClients gid + True -> interpretConversationStoreToPostgres (ConvStore.lookupHistoryClients gid) LookupMLSClientLeafIndices gid -> do logEffect "ConversationStore.LookupMLSClientLeafIndices" cid <- groupIdToConvId gid diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra/Queries.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra/Queries.hs index ba30f2f8ab9..563d02f3b2a 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra/Queries.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra/Queries.hs @@ -343,6 +343,15 @@ deleteSubConversation = "DELETE FROM subconversation where conv_id = ? and subco addMLSClient :: PrepQuery W (GroupId, Domain, UserId, ClientId, Int32) () addMLSClient = "insert into mls_group_member_client (group_id, user_domain, user, client, leaf_node_index, removal_pending) values (?, ?, ?, ?, ?, false)" +addHistoryClient :: PrepQuery W (GroupId, HistoryClientId, Int32) () +addHistoryClient = "insert into mls_history_client (group_id, id, leaf_node_index, removal_pending) values (?, ?, ?, false)" + +removeHistoryClient :: PrepQuery W (GroupId, HistoryClientId) () +removeHistoryClient = "delete from mls_history_client where group_id = ? and id = ?" + +removeAllHistoryClients :: PrepQuery W (Identity GroupId) () +removeAllHistoryClients = "DELETE FROM mls_history_client WHERE group_id = ?" + planMLSClientRemoval :: PrepQuery W (GroupId, Domain, UserId, ClientId) () planMLSClientRemoval = "update mls_group_member_client set removal_pending = true where group_id = ? and user_domain = ? and user = ? and client = ?" @@ -355,6 +364,9 @@ removeAllMLSClients = "DELETE FROM mls_group_member_client WHERE group_id = ?" lookupMLSClients :: PrepQuery R (Identity GroupId) (Domain, UserId, ClientId, Int32, Bool) lookupMLSClients = "select user_domain, user, client, leaf_node_index, removal_pending from mls_group_member_client where group_id = ?" +lookupHistoryClients :: PrepQuery R (Identity GroupId) (HistoryClientId, Int32, Bool) +lookupHistoryClients = "select id, leaf_node_index, removal_pending from mls_history_client where group_id = ?" + acquireCommitLock :: PrepQuery W (GroupId, Epoch, Int32) Row acquireCommitLock = "insert into mls_commit_locks (group_id, epoch) values (?, ?) if not exists using ttl ?" diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/MLS/Types.hs b/libs/wire-subsystems/src/Wire/ConversationStore/MLS/Types.hs index f3e620ed1de..9e0cf54c585 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/MLS/Types.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/MLS/Types.hs @@ -54,6 +54,17 @@ import Wire.API.MLS.LeafNode import Wire.API.MLS.SubConversation import Wire.StoredConversation +mkGroupMember :: + Maybe Domain -> + Maybe UserId -> + Maybe ClientId -> + Maybe HistoryClientId -> + Maybe GroupMember +mkGroupMember (Just dom) (Just uid) (Just cid) Nothing = + Just (RegularClient (ClientIdentity dom uid cid)) +mkGroupMember Nothing Nothing Nothing (Just hid) = Just (HistoryClient hid) +mkGroupMember _ _ _ _ = Nothing + -- | A map of leaf index to members. -- -- This is used to reconstruct client @@ -63,20 +74,29 @@ import Wire.StoredConversation -- Note that clients that are in the process of being removed from a group -- (i.e. there is a pending remove proposals for them) are included in this -- mapping. -newtype IndexMap = IndexMap {unIndexMap :: IntMap ClientIdentity} +newtype IndexMap = IndexMap {unIndexMap :: IntMap GroupMember} deriving (Eq, Show) deriving newtype (Semigroup, Monoid) -mkIndexMap :: [(Domain, UserId, ClientId, Int32, Bool)] -> IndexMap -mkIndexMap = IndexMap . foldr addEntry mempty +mkIndexMapFromParts :: + [(Domain, UserId, ClientId, Int32, Bool)] -> + [(HistoryClientId, Int32, Bool)] -> + IndexMap +mkIndexMapFromParts regular history = + IndexMap + . flip (foldr addHistoryClient) history + . flip (foldr addRegularClient) regular + $ mempty where - addEntry (dom, usr, c, leafidx, _pending_removal) = - IntMap.insert (fromIntegral leafidx) (ClientIdentity dom usr c) + addHistoryClient (h, leafidx, _) = + IntMap.insert (fromIntegral leafidx) (HistoryClient h) + addRegularClient (dom, usr, c, leafidx, _) = + IntMap.insert (fromIntegral leafidx) (RegularClient (ClientIdentity dom usr c)) -imLookup :: IndexMap -> LeafIndex -> Maybe ClientIdentity +imLookup :: IndexMap -> LeafIndex -> Maybe GroupMember imLookup m i = IntMap.lookup (fromIntegral i) (unIndexMap m) -imFromList :: [(LeafIndex, ClientIdentity)] -> IndexMap +imFromList :: [(LeafIndex, GroupMember)] -> IndexMap imFromList = IndexMap . IntMap.fromList . map (first fromIntegral) imNextIndex :: IndexMap -> LeafIndex @@ -84,10 +104,10 @@ imNextIndex im = fromIntegral . fromJust $ find (\n -> not $ IntMap.member n (unIndexMap im)) [0 ..] -imAddClient :: IndexMap -> ClientIdentity -> (LeafIndex, IndexMap) +imAddClient :: IndexMap -> GroupMember -> (LeafIndex, IndexMap) imAddClient im cid = let idx = imNextIndex im in (idx, IndexMap $ IntMap.insert (fromIntegral idx) cid $ unIndexMap im) -imRemoveClient :: IndexMap -> LeafIndex -> Maybe (ClientIdentity, IndexMap) +imRemoveClient :: IndexMap -> LeafIndex -> Maybe (GroupMember, IndexMap) imRemoveClient im idx = do cid <- imLookup im idx pure (cid, IndexMap . IntMap.delete (fromIntegral idx) $ unIndexMap im) @@ -98,7 +118,7 @@ imRemoveIndices keys = . flip IntMap.withoutKeys (IntSet.fromList (map fromIntegral keys)) . unIndexMap -imAssocs :: IndexMap -> [(Int, ClientIdentity)] +imAssocs :: IndexMap -> [(Int, GroupMember)] imAssocs = IntMap.assocs . unIndexMap -- | A two-level map of users to clients to leaf indices. @@ -111,6 +131,7 @@ imAssocs = IntMap.assocs . unIndexMap -- this mapping. newtype ClientMap a = ClientMap { unClientMap :: Map (Qualified UserId) (Map ClientId a) + -- TODO: add historyClients } deriving (Show, Eq, Functor) diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs index 881ccd38ea3..8496441213f 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs @@ -32,7 +32,6 @@ import Data.Misc import Data.Qualified import Data.Time import Data.Time.Calendar.OrdinalDate (fromOrdinalDate) -import Data.Tuple.Extra import Data.Vector (Vector) import Data.Vector qualified as Vector import Hasql.Pool qualified as Hasql @@ -55,6 +54,7 @@ import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.CellsState import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role +import Wire.API.History import Wire.API.MLS.CipherSuite import Wire.API.MLS.Credential import Wire.API.MLS.GroupInfo @@ -261,7 +261,8 @@ saveConvToPostgres allConvData = do meta.cnvmGroupConvType, meta.cnvmChannelAddPermission, meta.cnvmCellsState, - meta.cnvmParent + meta.cnvmParent, + fmap (.depth) (historyConfig meta.cnvmHistory) ) runTransactionWithRetry ReadCommitted Write $ do Transaction.statement convRow insertConv @@ -269,6 +270,7 @@ saveConvToPostgres allConvData = do Transaction.statement remoteMemberColumns insertRemoteMembers Transaction.statement subConvColumns insertSubConvs Transaction.statement mlsClientColumns insertMLSClients + Transaction.statement historyClientColumns insertHistoryClients Transaction.statement (DeleteConv, storedConv.id_) markDeletionPendingStmt where storedConv = allConvData.conv @@ -294,21 +296,22 @@ saveConvToPostgres allConvData = do Maybe GroupConvType, Maybe AddPermission, CellsState, - Maybe ConvId + Maybe ConvId, + Maybe HistoryDuration ) () insertConv = - lmapPG @(_, _, _, Vector Int32, Vector Int32, _, _, _, _, _, _, _, _, _, _, _, _, _, _) @_ + lmapPG @(_, _, _, Vector Int32, Vector Int32, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _) @_ [resultlessStatement|INSERT INTO conversation (id, type, creator, access, access_roles_v2, name, team, message_timer, receipt_mode, protocol, group_id, epoch, epoch_timestamp, cipher_suite, public_group_state, - group_conv_type, channel_add_permission, cells_state, parent_conv) + group_conv_type, channel_add_permission, cells_state, parent_conv, history_depth) VALUES ($1 :: uuid, $2 :: integer, $3 :: uuid?, $4 :: integer[], $5 :: integer[], $6 :: text?, $7 :: uuid?, $8 :: bigint?, $9 :: integer?, $10 :: integer, $11 :: bytea?, $12 :: bigint?, $13 :: timestamptz?, $14 :: integer?, $15 :: bytea?, - $16 ::integer?, $17 :: integer?, $18 :: integer, $19 :: uuid?) + $16 ::integer?, $17 :: integer?, $18 :: integer, $19 :: uuid?, $20 :: bigint?) ON CONFLICT (id) DO NOTHING |] @@ -384,21 +387,18 @@ saveConvToPostgres allConvData = do mlsClientRows :: GroupId -> ClientMap LeafIndex -> IndexMap -> [(GroupId, Domain, UserId, ClientId, Int32, Bool)] mlsClientRows gid clientMap indexMap = - let clients :: [(LeafIndex, ClientIdentity, Bool)] = - IntMap.elems $ - IntMap.mapWithKey - (\idx ci -> (fromIntegral idx, ci, isNothing (cmLookupIndex ci clientMap))) - indexMap.unIndexMap + let clients :: [(LeafIndex, ClientIdentity, Bool)] = do + (idx, element) <- IntMap.assocs indexMap.unIndexMap + case element of + RegularClient ci -> + pure (fromIntegral idx, ci, isNothing (cmLookupIndex ci clientMap)) + HistoryClient _ -> [] in flip map clients $ \(idx, ci, removalPending) -> (gid, ci.ciDomain, ci.ciUser, ci.ciClient, fromIntegral idx, removalPending) mlsClientColumns :: ([GroupId], [Domain], [UserId], [ClientId], [Int32], [Bool]) mlsClientColumns = - let mainConvGroupId = cnvmlsGroupId <$> getMLSData storedConv.protocol - mainConvInputs = maybeToList $ (,,) <$> mainConvGroupId <*> (fmap (.clientMap) allConvData.mlsDetails) <*> (fmap (.indexMap) allConvData.mlsDetails) - subConvsInputs = flip map allConvData.subConvs $ \(AllSubConvData sc _) -> (sc.scMLSData.cnvmlsGroupId, sc.scMembers, sc.scIndexMap) - allInputs = mainConvInputs <> subConvsInputs - allRows = concatMap (uncurry3 mlsClientRows) allInputs + let allRows = concatMap (\(gid, clientMap, indexMap, _) -> mlsClientRows gid clientMap indexMap) mlsInputs in unzip6 allRows insertMLSClients :: Hasql.Statement ([GroupId], [Domain], [UserId], [ClientId], [Int32], [Bool]) () @@ -411,6 +411,36 @@ saveConvToPostgres allConvData = do $4 :: text[], $5 :: integer[], $6 :: bool[]) |] + historyClientColumns :: ([GroupId], [HistoryClientId], [Int32], [Bool]) + historyClientColumns = + let allRows = concatMap (\(gid, _, _, hclients) -> fmap (\(hid, idx, removal) -> (gid, hid, idx, removal)) hclients) mlsInputs + in unzip4 allRows + + insertHistoryClients :: Hasql.Statement ([GroupId], [HistoryClientId], [Int32], [Bool]) () + insertHistoryClients = + lmapPG @(Vector _, Vector _, Vector _, Vector _) @_ + [resultlessStatement|INSERT INTO mls_history_client + (group_id, id, leaf_node_index, removal_pending) + SELECT * + FROM UNNEST ($1 :: bytea[], $2 :: uuid[], $3 :: integer[], $4 :: bool[]) + |] + + mlsInputs :: [(GroupId, ClientMap LeafIndex, IndexMap, [(HistoryClientId, Int32, Bool)])] + mlsInputs = + let mainConvGroupId = cnvmlsGroupId <$> getMLSData storedConv.protocol + mainConvInputs = + maybeToList $ + (,,,) + <$> mainConvGroupId + <*> (fmap (.clientMap) allConvData.mlsDetails) + <*> (fmap (.indexMap) allConvData.mlsDetails) + <*> (fmap (.historyClients) allConvData.mlsDetails) + subConvsInputs = + fmap + (\scData -> (scData.subConv.scMLSData.cnvmlsGroupId, scData.subConv.scMembers, scData.subConv.scIndexMap, scData.historyClients)) + allConvData.subConvs + in mainConvInputs <> subConvsInputs + zeroTime :: UTCTime zeroTime = UTCTime (fromOrdinalDate 1970 1) 0 diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Migration/Cleanup.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Migration/Cleanup.hs index e26533da7b5..fb20f64d2ee 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Migration/Cleanup.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Migration/Cleanup.hs @@ -70,14 +70,15 @@ getAllConvData cid = do Just conv -> do subConvMlsData <- listSubConversations cid mGroupInfo <- getGroupInfo cid - mlsLeafIndices <- case mlsMetadata conv of + mlsDetails <- case mlsMetadata conv of Nothing -> pure Nothing Just (mlsData, _) -> do (cm, im) <- lookupMLSClientLeafIndices mlsData.cnvmlsGroupId - pure $ Just (cm, im) - let mlsDetails = ConvMLSDetails <$> mGroupInfo <*> fmap fst mlsLeafIndices <*> fmap snd mlsLeafIndices + historyClients <- lookupHistoryClients mlsData.cnvmlsGroupId + pure $ ConvMLSDetails <$> mGroupInfo <*> pure cm <*> pure im <*> pure historyClients subConvs <- fmap Map.elems $ flip Map.traverseWithKey subConvMlsData $ \subConvId mlsData -> do (cm, im) <- lookupMLSClientLeafIndices mlsData.cnvmlsGroupId + historyClients <- lookupHistoryClients mlsData.cnvmlsGroupId let subconv = SubConversation { scParentConvId = cid, @@ -87,7 +88,7 @@ getAllConvData cid = do scIndexMap = im } gi <- getSubConversationGroupInfo cid subConvId - pure $ AllSubConvData subconv gi + pure $ AllSubConvData subconv gi historyClients pure . Just $ AllConvData {..} deleteConv :: (Member ConversationStore r) => AllConvData -> Sem r () diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Migration/Types.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Migration/Types.hs index 39487845d65..1ecd7aa3d27 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Migration/Types.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Migration/Types.hs @@ -19,6 +19,7 @@ module Wire.ConversationStore.Migration.Types where +import Data.Id import Imports import Wire.API.MLS.GroupInfo import Wire.API.MLS.LeafNode @@ -28,12 +29,14 @@ import Wire.StoredConversation data ConvMLSDetails = ConvMLSDetails { groupInfoData :: GroupInfoData, clientMap :: ClientMap LeafIndex, - indexMap :: IndexMap + indexMap :: IndexMap, + historyClients :: [(HistoryClientId, Int32, Bool)] } data AllSubConvData = AllSubConvData { subConv :: SubConversation, - groupInfoData :: Maybe GroupInfoData + groupInfoData :: Maybe GroupInfoData, + historyClients :: [(HistoryClientId, Int32, Bool)] } data AllConvData = AllConvData diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs index 2db307aa8b1..e603ce3bfe5 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs @@ -112,10 +112,14 @@ interpretConversationStoreToPostgres = interpret $ \case DeleteMembers cnv ul -> deleteMembersImpl cnv ul DeleteMembersInRemoteConversation rcnv uids -> deleteMembersInRemoteConversationImpl rcnv uids AddMLSClients lcnv quid cs -> addMLSClientsImpl lcnv quid cs + AddHistoryClient groupId hid idx -> addHistoryClientImpl groupId hid idx + RemoveHistoryClient groupId hid -> removeHistoryClientImpl groupId hid + RemoveAllHistoryClients groupId -> removeAllHistoryClientsImpl groupId PlanClientRemoval lcnv cids -> planClientRemovalImpl lcnv cids RemoveMLSClients lcnv quid cs -> removeMLSClientsImpl lcnv quid cs RemoveAllMLSClients gid -> removeAllMLSClientsImpl gid LookupMLSClients lcnv -> lookupMLSClientsImpl lcnv + LookupHistoryClients gid -> lookupHistoryClientsImpl gid LookupMLSClientLeafIndices lcnv -> lookupMLSClientLeafIndicesImpl lcnv UpsertSubConversation convId subConvId groupId -> createSubConversationImpl convId subConvId groupId GetSubConversation convId subConvId -> getSubConversationImpl convId subConvId @@ -1009,6 +1013,44 @@ addMLSClientsImpl gid (Qualified uid domain) clients = ($1 :: bytea, $2 :: text, $3 :: uuid, $4 :: text, $5 :: integer, false) |] +addHistoryClientImpl :: (PGConstraints r) => GroupId -> HistoryClientId -> LeafIndex -> Sem r () +addHistoryClientImpl gid hid idx = + runPipeline $ + Pipeline.statement (gid, hid, fromIntegral idx) insert + where + insert :: Hasql.Statement (GroupId, HistoryClientId, Int32) () + insert = + lmapPG + [resultlessStatement|INSERT INTO mls_history_client + (group_id, id, leaf_node_index, removal_pending) + VALUES + ($1 :: bytea, $2 :: uuid, $3 :: integer, false) + |] + +removeHistoryClientImpl :: (PGConstraints r) => GroupId -> HistoryClientId -> Sem r () +removeHistoryClientImpl gid hid = + runPipeline $ + Pipeline.statement (gid, hid) delete + where + delete :: Hasql.Statement (GroupId, HistoryClientId) () + delete = + lmapPG + [resultlessStatement|DELETE FROM mls_history_client + WHERE group_id = ($1 :: bytea) + AND id = ($2 :: uuid) + |] + +removeAllHistoryClientsImpl :: (PGConstraints r) => GroupId -> Sem r () +removeAllHistoryClientsImpl gid = + runStatement gid delete + where + delete :: Hasql.Statement GroupId () + delete = + lmapPG + [resultlessStatement|DELETE FROM mls_history_client + WHERE group_id = ($1 :: bytea) + |] + planClientRemovalImpl :: (PGConstraints r, Foldable f) => GroupId -> f ClientIdentity -> Sem r () planClientRemovalImpl gid clients = runPipeline $ @@ -1065,10 +1107,22 @@ selectMLSClients = WHERE group_id = ($1 :: bytea) |] +selectHistoryClients :: Hasql.Statement GroupId [(HistoryClientId, Int32, Bool)] +selectHistoryClients = + dimapPG + [vectorStatement|SELECT (id :: uuid), (leaf_node_index :: integer), (removal_pending :: bool) + FROM mls_history_client + WHERE group_id = ($1 :: bytea) + |] + +lookupHistoryClientsImpl :: (PGConstraints r) => GroupId -> Sem r [(HistoryClientId, Int32, Bool)] +lookupHistoryClientsImpl gid = runStatement gid selectHistoryClients + lookupMLSClientLeafIndicesImpl :: (PGConstraints r) => GroupId -> Sem r (ClientMap LeafIndex, IndexMap) lookupMLSClientLeafIndicesImpl gid = do - rows <- runStatement gid selectMLSClients - pure (mkClientMap rows, mkIndexMap rows) + regularClients <- runStatement gid selectMLSClients + historyClients <- lookupHistoryClientsImpl gid + pure (mkClientMap regularClients, mkIndexMapFromParts regularClients historyClients) -- SUB CONVERSATION OPERATIONS createSubConversationImpl :: (PGConstraints r) => ConvId -> SubConvId -> GroupId -> Sem r SubConversation diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs index 3c81ed700f9..4ecfd667a63 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs @@ -377,6 +377,7 @@ instance IsConversationAction 'ConversationDeleteTag where storedConv = tUnqualified lconv let deleteGroup groupId = do E.removeAllMLSClients groupId + E.removeAllHistoryClients groupId E.deleteAllProposals groupId let cid = storedConv.id_ diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Errors.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Errors.hs index ec74e2b5267..ae8208020de 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Errors.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Errors.hs @@ -103,6 +103,8 @@ data ConversationSubsystemError | ConversationSubsystemErrorMLSOutOfSyncError MLSOutOfSyncError | ConversationSubsystemErrorNonFederatingBackends NonFederatingBackends | ConversationSubsystemErrorUnreachableBackendsLegacy UnreachableBackendsLegacy + | ConversationSubsystemErrorMLSHistoryClientConflict + | ConversationSubsystemErrorMLSHistoryClientDuplication instance APIError ConversationSubsystemError where toResponse = @@ -174,6 +176,8 @@ instance APIError ConversationSubsystemError where ConversationSubsystemErrorMLSOutOfSyncError x -> toResponse x ConversationSubsystemErrorNonFederatingBackends x -> toResponse x ConversationSubsystemErrorUnreachableBackendsLegacy x -> toResponse x + ConversationSubsystemErrorMLSHistoryClientConflict -> toResponse $ Tagged @'MLSHistoryClientConflict () + ConversationSubsystemErrorMLSHistoryClientDuplication -> toResponse $ Tagged @'MLSHistoryClientDuplication () type ConversationSubsystemErrorEffects = '[ ErrorS 'ConvAccessDenied, @@ -244,7 +248,9 @@ type ConversationSubsystemErrorEffects = Error MLSOutOfSyncError, Error MLSProposalFailure, Error NonFederatingBackends, - Error UnreachableBackendsLegacy + Error UnreachableBackendsLegacy, + ErrorS 'MLSHistoryClientConflict, + ErrorS 'MLSHistoryClientDuplication ] mapErrors :: @@ -254,7 +260,9 @@ mapErrors :: ) => InterpretersFor ConversationSubsystemErrorEffects r mapErrors = - mapError (ConversationSubsystemErrorUnreachableBackendsLegacy) + mapError (const ConversationSubsystemErrorMLSHistoryClientDuplication) + . mapError (const ConversationSubsystemErrorMLSHistoryClientConflict) + . mapError (ConversationSubsystemErrorUnreachableBackendsLegacy) . mapError (ConversationSubsystemErrorNonFederatingBackends) . interpretServerEffect . mapError (ConversationSubsystemErrorMLSOutOfSyncError) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/Core.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/Core.hs index a51f0adcbcd..c54c5474cf8 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/Core.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/Core.hs @@ -122,7 +122,7 @@ getCommitData senderIdentity lConvOrSub epoch ciphersuite bundle = do runState convOrSub.indexMap $ do creatorAction <- if epoch == Epoch 0 - then addProposedClient (Left senderIdentity.client) + then addProposedClient (Left . RegularClient $ senderIdentity.client) else mempty proposals <- traverse @@ -260,7 +260,7 @@ checkUpdatePath :: checkUpdatePath lConvOrSub senderIdentity ciphersuite path = for_ senderIdentity.index $ \index -> do let groupId = cnvmlsGroupId (tUnqualified lConvOrSub).mlsMeta let extra = LeafNodeTBSExtraCommit groupId index - case validateLeafNode ciphersuite (Just senderIdentity.client) extra path.leaf.value of + case validateLeafNode ciphersuite (Just . RegularClient $ senderIdentity.client) extra path.leaf.value of Left InvalidLeafNodeSignature -> throwS @'MLSInvalidLeafNodeSignature Left errMsg -> throw $ diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/ExternalCommit.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/ExternalCommit.hs index 10fa4e6bd58..c711391146c 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/ExternalCommit.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/ExternalCommit.hs @@ -114,7 +114,7 @@ getExternalCommitData senderIdentity lConvOrSub epoch commit = do -- add sender client im <- get - let (addedIndex, im') = imAddClient im senderIdentity + let (addedIndex, im') = imAddClient im (RegularClient senderIdentity) put im' pure diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/InternalCommit.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/InternalCommit.hs index 8abc46eefb0..c1fb96838f1 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/InternalCommit.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Commit/InternalCommit.hs @@ -234,16 +234,21 @@ processInternalCommit senderIdentity con lConvOrSub ciphersuite ciphersuiteUpdat pure (addEvents <> removeEvents) else pure [] + let gid = cnvmlsGroupId convOrSub.mlsMeta -- Remove clients from the conversation state. This includes client removals -- of all types (see Note [client removal]). for_ (Map.assocs (unClientMap (paRemove action))) $ \(qtarget, clients) -> do - removeMLSClients (cnvmlsGroupId convOrSub.mlsMeta) qtarget (Map.keysSet clients) + removeMLSClients gid qtarget (Map.keysSet clients) -- add clients to the conversation state for_ newUserClients $ \(qtarget, newClients) -> do - addMLSClients (cnvmlsGroupId convOrSub.mlsMeta) qtarget $ + addMLSClients gid qtarget $ Set.fromList [(cid, idx) | (cid, (idx, _)) <- Map.assocs newClients] + for_ action.paHistoryClientAdd $ uncurry (addHistoryClient gid) + + for_ action.paHistoryClientRemove $ \(hid, _) -> removeHistoryClient gid hid + -- set cipher suite when ciphersuiteUpdate $ case convOrSub.id of Conv cid -> setConversationCipherSuite cid ciphersuite diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/GroupInfoCheck.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/GroupInfoCheck.hs index d3bdabf3e3b..93773d657f5 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/GroupInfoCheck.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/GroupInfoCheck.hs @@ -45,7 +45,7 @@ import Wire.ConversationStore.MLS.Types import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem, getFeatureForTeam) data GroupInfoMismatch = GroupInfoMismatch - {clients :: [(Int, ClientIdentity)]} + {clients :: [(Int, GroupMember)]} deriving (Show) newtype GroupInfoCheckEnabled @@ -85,7 +85,7 @@ groupStateMismatch leaves groupInfo = do giLeaves <- imFromList <$> traverse (traverse getIdentity) (ratchetTreeLeaves tree) pure $ guard (leaves /= giLeaves) $> GroupInfoMismatch (imAssocs leaves) where - getIdentity :: LeafNode -> Either Text ClientIdentity + getIdentity :: LeafNode -> Either Text GroupMember getIdentity leaf = fst <$> credentialIdentityAndKey leaf.credential existingGroupStateMismatch :: diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs index dde9267c7e8..42c98aeecdc 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs @@ -27,6 +27,7 @@ where import Control.Monad.Codensity import Data.Domain import Data.Id +import Data.IntMap qualified as IntMap import Data.Json.Util import Data.LegalHold import Data.Map qualified as Map @@ -52,6 +53,7 @@ import Wire.API.Federation.API import Wire.API.Federation.API.Galley import Wire.API.Federation.Client (FederatorClient) import Wire.API.Federation.Error +import Wire.API.History import Wire.API.MLS.CipherSuite import Wire.API.MLS.Commit hiding (output) import Wire.API.MLS.CommitBundle @@ -111,7 +113,9 @@ type MLSMessageStaticErrors = ErrorS 'MLSCommitMissingReferences, ErrorS 'MLSSelfRemovalNotAllowed, ErrorS 'MLSClientSenderUserMismatch, - ErrorS 'MLSSubConvClientNotInParent + ErrorS 'MLSSubConvClientNotInParent, + ErrorS 'MLSHistoryClientConflict, + ErrorS 'MLSHistoryClientDuplication ] enableOutOfSyncCheckFromVersion :: Version -> EnableOutOfSyncCheck @@ -136,7 +140,9 @@ postMLSMessageFromLocalUser :: Member (ErrorS 'MLSSubConvClientNotInParent) r, Member (ErrorS MLSInvalidLeafNodeSignature) r, Member (Error MLSOutOfSyncError) r, - Member (Error GroupInfoDiagnostics) r + Member (Error GroupInfoDiagnostics) r, + Member (ErrorS MLSHistoryClientConflict) r, + Member (ErrorS MLSHistoryClientDuplication) r ) => Version -> Local UserId -> @@ -305,12 +311,19 @@ postMLSCommitBundleToLocalConv qusr c conn bundle ctype lConvOrSubId = do lift $ getCommitData senderIdentity lConvOrSub bundle.epoch ciphersuite bundle - -- reject message if the conversation is out of sync lift $ do + let sharedHistoryEnabled = isJust $ historyConfig convOrSub.meta.cnvmHistory + let historyClients = filter isHistoryClient (IntMap.elems newIndexMap.unIndexMap) + case historyClients of + (_ : _ : _) -> throwS @'MLSHistoryClientDuplication + _ -> pure () + let historyClientExists = not (null historyClients) + when (sharedHistoryEnabled /= historyClientExists) $ throwS @'MLSHistoryClientConflict + + -- reject message if the conversation is out of sync let newUsers = Map.keysSet (unClientMap action.paAdd) checkConversationOutOfSync newUsers lConvOrSub ciphersuite - lift $ checkGroupState convOrSub newIndexMap bundle.groupInfo.value -- process additions and removals @@ -456,7 +469,9 @@ postMLSMessage :: Member (ErrorS 'MLSSubConvClientNotInParent) r, Member (ErrorS MLSInvalidLeafNodeSignature) r, Member (Error MLSOutOfSyncError) r, - Member (Error GroupInfoDiagnostics) r + Member (Error GroupInfoDiagnostics) r, + Member (ErrorS MLSHistoryClientConflict) r, + Member (ErrorS MLSHistoryClientDuplication) r ) => Local x -> Qualified UserId -> @@ -491,7 +506,7 @@ getSenderIdentity qusr c mSender lConvOrSubConv = do SenderMember idx -> do when (epoch > 0) $ do cid' <- note (mlsProtocolError "unknown sender leaf index") $ imLookup (tUnqualified lConvOrSubConv).indexMap idx - unless (cid' == cid) $ throwS @'MLSClientSenderUserMismatch + unless (cid' == RegularClient cid) $ throwS @'MLSClientSenderUserMismatch pure (Just idx) _ -> pure Nothing pure SenderIdentity {client = cid, index} @@ -504,7 +519,8 @@ postMLSMessageToLocalConv :: Member (ErrorS 'MLSUnsupportedMessage) r, Member (Error MLSOutOfSyncError) r, Member (ErrorS MLSInvalidLeafNodeSignature) r, - Member (Input EnableOutOfSyncCheck) r + Member (Input EnableOutOfSyncCheck) r, + Member (ErrorS MLSHistoryClientConflict) r ) => Qualified UserId -> ClientId -> @@ -528,7 +544,8 @@ validateMessage :: Member (ErrorS MLSUnsupportedMessage) r, Member (Error MLSOutOfSyncError) r, Member (ErrorS MLSInvalidLeafNodeSignature) r, - Member (Input EnableOutOfSyncCheck) r + Member (Input EnableOutOfSyncCheck) r, + Member (ErrorS MLSHistoryClientConflict) r ) => Qualified UserId -> ClientId -> @@ -578,6 +595,12 @@ validateMessage qusr c lConvOrSub mEpoch msg = do ) $ throwS @'MLSStaleMessage + -- once an admin toggles history sharing, every subsequent application message will be rejected + -- until a commit that adds or removes the history client is processed. + let sharedHistoryEnabled = isJust $ historyConfig convOrSub.meta.cnvmHistory + let historyClientExists = any isHistoryClient (IntMap.elems convOrSub.indexMap.unIndexMap) + when (sharedHistoryEnabled /= historyClientExists) $ throwS @'MLSHistoryClientConflict + postMLSMessageToRemoteConv :: ( Members MLSMessageStaticErrors r, HasProposalEffects r, diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Proposal.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Proposal.hs index 712fd32aee4..083b1b77d20 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Proposal.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Proposal.hs @@ -79,22 +79,26 @@ import Wire.Util data ProposalAction = ProposalAction { paAdd :: ClientMap (LeafIndex, Maybe KeyPackage), - paRemove :: ClientMap LeafIndex + paRemove :: ClientMap LeafIndex, + paHistoryClientAdd :: Set (HistoryClientId, LeafIndex), + paHistoryClientRemove :: Set (HistoryClientId, LeafIndex) } deriving (Show) instance Semigroup ProposalAction where - ProposalAction add1 rem1 <> ProposalAction add2 rem2 = - ProposalAction (add1 <> add2) (rem1 <> rem2) + ProposalAction add1 rem1 hadd1 hrem1 <> ProposalAction add2 rem2 hadd2 hrem2 = + ProposalAction (add1 <> add2) (rem1 <> rem2) (hadd1 <> hadd2) (hrem1 <> hrem2) instance Monoid ProposalAction where - mempty = ProposalAction mempty mempty + mempty = ProposalAction mempty mempty mempty mempty -paAddClient :: ClientIdentity -> LeafIndex -> Maybe KeyPackage -> ProposalAction -paAddClient cid idx kp = mempty {paAdd = cmSingleton cid (idx, kp)} +paAddClient :: GroupMember -> LeafIndex -> Maybe KeyPackage -> ProposalAction +paAddClient (RegularClient cid) idx kp = mempty {paAdd = cmSingleton cid (idx, kp)} +paAddClient (HistoryClient hid) idx _ = mempty {paHistoryClientAdd = Set.singleton (hid, idx)} -paRemoveClient :: ClientIdentity -> LeafIndex -> ProposalAction -paRemoveClient cid idx = mempty {paRemove = cmSingleton cid idx} +paRemoveClient :: GroupMember -> LeafIndex -> ProposalAction +paRemoveClient (RegularClient cid) idx = mempty {paRemove = cmSingleton cid idx} +paRemoveClient (HistoryClient hid) idx = mempty {paHistoryClientRemove = Set.singleton (hid, idx)} -- | This is used to sort proposals into the correct processing order, as defined by the spec data ProposalProcessingStage @@ -179,7 +183,7 @@ addProposedClient :: ( Member (State IndexMap) r, Member (ErrorS MLSUnsupportedProposal) r ) => - Either ClientIdentity KeyPackage -> + Either GroupMember KeyPackage -> Sem r ProposalAction addProposedClient cidOrKp = do (cid, mKp) <- case cidOrKp of @@ -280,7 +284,7 @@ processProposal qusr lConvOrSub groupId epoch pub prop = do getKeyPackageIdentity :: (Member (ErrorS 'MLSUnsupportedProposal) r) => KeyPackage -> - Sem r ClientIdentity + Sem r GroupMember getKeyPackageIdentity = either (\_ -> throwS @'MLSUnsupportedProposal) pure . keyPackageIdentity @@ -304,15 +308,19 @@ checkExternalProposalUser qusr prop = do loc ( \lusr -> case prop of AddProposal kp -> do - ClientIdentity {ciUser, ciClient} <- getKeyPackageIdentity kp.value - -- requesting user must match key package owner - when (tUnqualified lusr /= ciUser) $ throwS @'MLSUnsupportedProposal - -- client referenced in key package must be one of the user's clients - UserClients {userClients} <- lookupClients [ciUser] - maybe - (throwS @'MLSUnsupportedProposal) - (flip when (throwS @'MLSUnsupportedProposal) . Set.null . Set.filter (== ciClient)) - $ userClients Map.!? ciUser + groupMember <- getKeyPackageIdentity kp.value + case groupMember of + RegularClient (ClientIdentity {ciUser, ciClient}) -> do + -- requesting user must match key package owner + when (tUnqualified lusr /= ciUser) $ throwS @'MLSUnsupportedProposal + -- client referenced in key package must be one of the user's clients + UserClients {userClients} <- lookupClients [ciUser] + maybe + (throwS @'MLSUnsupportedProposal) + (flip when (throwS @'MLSUnsupportedProposal) . Set.null . Set.filter (== ciClient)) + $ userClients Map.!? ciUser + -- We currently do not support history-client adds in external proposals/commits. + HistoryClient _ -> throwS @'MLSUnsupportedProposal _ -> throwS @'MLSUnsupportedProposal ) (const $ pure ()) -- FUTUREWORK: check external proposals from remote backends diff --git a/postgres-schema.sql b/postgres-schema.sql index 36b3260dfcd..2f9f0effcea 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -269,6 +269,20 @@ CREATE TABLE public.mls_group_member_client ( ALTER TABLE public.mls_group_member_client OWNER TO "wire-server"; +-- +-- Name: mls_history_client; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.mls_history_client ( + group_id bytea NOT NULL, + id uuid NOT NULL, + leaf_node_index integer NOT NULL, + removal_pending boolean NOT NULL +); + + +ALTER TABLE public.mls_history_client OWNER TO "wire-server"; + -- -- Name: remote_conversation_local_member; Type: TABLE; Schema: public; Owner: wire-server -- @@ -468,6 +482,14 @@ ALTER TABLE ONLY public.mls_group_member_client ADD CONSTRAINT mls_group_member_client_pkey PRIMARY KEY (group_id, user_domain, "user", client); +-- +-- Name: mls_history_client mls_history_client_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.mls_history_client + ADD CONSTRAINT mls_history_client_pkey PRIMARY KEY (group_id, id); + + -- -- Name: remote_conversation_local_member remote_conversation_local_member_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -559,6 +581,13 @@ CREATE INDEX conversation_codes_key_expires_at_idx ON public.conversation_codes CREATE INDEX conversation_member_user_idx ON public.conversation_member USING btree ("user"); +-- +-- Name: conversation_parent_conv_idx; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX conversation_parent_conv_idx ON public.conversation USING btree (parent_conv); + + -- -- Name: conversation_team_group_type_lower_name_id_idx; Type: INDEX; Schema: public; Owner: wire-server -- diff --git a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs index 09a2fc56bc0..45173c9eb66 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs @@ -53,7 +53,7 @@ validateUploadedKeyPackage :: RawMLS KeyPackage -> Handler r (KeyPackageRef, CipherSuiteTag, KeyPackageData) validateUploadedKeyPackage identity kp = do - (cs, lt) <- either mlsProtocolErrorFromValidationError pure $ validateKeyPackage (Just identity) kp.value + (cs, lt) <- either mlsProtocolErrorFromValidationError pure $ validateKeyPackage (Just $ RegularClient identity) kp.value validateLifetime lt diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 760d46ae5de..4000436cb41 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -109,6 +109,7 @@ library Galley.Schema.V100_OutOfSync Galley.Schema.V101_ConversationLowerGCGracePeriod Galley.Schema.V102_ConversationHistory + Galley.Schema.V103_HistoryClient Galley.Schema.V20 Galley.Schema.V21 Galley.Schema.V22 diff --git a/services/galley/src/Galley/Schema/Run.hs b/services/galley/src/Galley/Schema/Run.hs index 76a76b40175..65f0b8725a4 100644 --- a/services/galley/src/Galley/Schema/Run.hs +++ b/services/galley/src/Galley/Schema/Run.hs @@ -23,6 +23,7 @@ import Control.Exception (finally) import Galley.Schema.V100_OutOfSync qualified as V100_OutOfSync import Galley.Schema.V101_ConversationLowerGCGracePeriod qualified as V101_ConversationLowerGCGracePeriod import Galley.Schema.V102_ConversationHistory qualified as V102_ConversationHistory +import Galley.Schema.V103_HistoryClient qualified as V103_HistoryClient import Galley.Schema.V20 qualified as V20 import Galley.Schema.V21 qualified as V21 import Galley.Schema.V22 qualified as V22 @@ -206,7 +207,8 @@ migrations = V99_ConversationAddParent.migration, V100_OutOfSync.migration, V101_ConversationLowerGCGracePeriod.migration, - V102_ConversationHistory.migration + V102_ConversationHistory.migration, + V103_HistoryClient.migration -- FUTUREWORK: once #1726 has made its way to master/production, -- the 'message' field in connections table can be dropped. -- See also https://github.com/wireapp/wire-server/pull/1747/files diff --git a/services/galley/src/Galley/Schema/V103_HistoryClient.hs b/services/galley/src/Galley/Schema/V103_HistoryClient.hs new file mode 100644 index 00000000000..2bdca9d2cd6 --- /dev/null +++ b/services/galley/src/Galley/Schema/V103_HistoryClient.hs @@ -0,0 +1,37 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +module Galley.Schema.V103_HistoryClient + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = + Migration 103 "add history client table" $ do + schema' + [r| CREATE TABLE mls_history_client ( + group_id blob, + id uuid, + leaf_node_index int, + removal_pending boolean, + PRIMARY KEY (group_id, id) + ) + |]