diff --git a/cassandra-schema.cql b/cassandra-schema.cql index bd93b6c0ab..d300556af7 100644 --- a/cassandra-schema.cql +++ b/cassandra-schema.cql @@ -222,9 +222,10 @@ CREATE TABLE brig_test.user_cookies ( CREATE TABLE brig_test.mls_key_packages ( user uuid, client text, + cipher_suite int, ref blob, data blob, - PRIMARY KEY ((user, client), ref) + PRIMARY KEY ((user, client, cipher_suite), ref) ) WITH CLUSTERING ORDER BY (ref ASC) AND bloom_filter_fp_chance = 0.1 AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'} diff --git a/changelog.d/1-api-changes/mls-key-package-ciphersuites b/changelog.d/1-api-changes/mls-key-package-ciphersuites new file mode 100644 index 0000000000..9ed10cbd19 --- /dev/null +++ b/changelog.d/1-api-changes/mls-key-package-ciphersuites @@ -0,0 +1 @@ +The key package API has gained a `ciphersuite` query parameter, which should be the hexadecimal value of an MLS ciphersuite, defaulting to `0x0001`. The `ciphersuite` parameter is used by the claim and count endpoints. For uploads, the API is unchanged, and the ciphersuite is taken directly from the uploaded key package. diff --git a/changelog.d/2-features/mls-ciphersuites b/changelog.d/2-features/mls-ciphersuites new file mode 100644 index 0000000000..7886487e8b --- /dev/null +++ b/changelog.d/2-features/mls-ciphersuites @@ -0,0 +1 @@ +Added support for post-quantum ciphersuite 0xf031. Correspondingly, MLS groups with a non-default ciphersuite are now supported. The first commit in a group determines the group ciphersuite. diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index 91206de95e..6a780e19ff 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -254,18 +254,22 @@ uploadKeyPackage cid kp = do & addJSONObject ["key_packages" .= [T.decodeUtf8 (Base64.encode kp)]] ) -claimKeyPackages :: (MakesValue u, MakesValue v) => u -> v -> App Response -claimKeyPackages u v = do +claimKeyPackages :: (MakesValue u, MakesValue v) => Ciphersuite -> u -> v -> App Response +claimKeyPackages suite u v = do (targetDom, targetUid) <- objQid v req <- baseRequest u Brig Versioned $ "/mls/key-packages/claim/" <> targetDom <> "/" <> targetUid - submit "POST" req + submit "POST" $ + req + & addQueryParams [("ciphersuite", suite.code)] -countKeyPackages :: ClientIdentity -> App Response -countKeyPackages cid = do - baseRequest cid Brig Versioned ("/mls/key-packages/self/" <> cid.client <> "/count") - >>= submit "GET" +countKeyPackages :: Ciphersuite -> ClientIdentity -> App Response +countKeyPackages suite cid = do + req <- baseRequest cid Brig Versioned ("/mls/key-packages/self/" <> cid.client <> "/count") + submit "GET" $ + req + & addQueryParams [("ciphersuite", suite.code)] deleteKeyPackages :: ClientIdentity -> [String] -> App Response deleteKeyPackages cid kps = do diff --git a/integration/test/MLS/Util.hs b/integration/test/MLS/Util.hs index 9d25380041..969e954f6e 100644 --- a/integration/test/MLS/Util.hs +++ b/integration/test/MLS/Util.hs @@ -76,20 +76,17 @@ randomFileName = do mlscli :: HasCallStack => ClientIdentity -> [String] -> Maybe ByteString -> App ByteString mlscli cid args mbstdin = do - bd <- getBaseDir - let cdir = bd cid2Str cid - groupOut <- randomFileName let substOut = argSubst "" groupOut - hasState <- hasClientGroupState cid - substIn <- - if hasState - then do - gs <- getClientGroupState cid - fn <- toRandomFile gs - pure (argSubst "" fn) - else pure id + gs <- getClientGroupState cid + + substIn <- case gs.group of + Nothing -> pure id + Just groupData -> do + fn <- toRandomFile groupData + pure (argSubst "" fn) + store <- maybe randomFileName toRandomFile gs.keystore let args' = map (substIn . substOut) args for_ args' $ \arg -> @@ -100,16 +97,25 @@ mlscli cid args mbstdin = do spawn ( proc "mls-test-cli" - ( ["--store", cdir "store"] + ( ["--store", store] <> args' ) ) mbstdin - groupOutWritten <- liftIO $ doesFileExist groupOut - when groupOutWritten $ do - gs <- liftIO (BS.readFile groupOut) - setClientGroupState cid gs + setGroup <- do + groupOutWritten <- liftIO $ doesFileExist groupOut + if groupOutWritten + then do + groupData <- liftIO (BS.readFile groupOut) + pure $ \x -> x {group = Just groupData} + else pure id + setStore <- do + storeData <- liftIO (BS.readFile store) + pure $ \x -> x {keystore = Just storeData} + + setClientGroupState cid ((setGroup . setStore) gs) + pure out argSubst :: String -> String -> String -> String @@ -125,8 +131,9 @@ createWireClient u = do initMLSClient :: HasCallStack => ClientIdentity -> App () initMLSClient cid = do bd <- getBaseDir + mls <- getMLSState liftIO $ createDirectory (bd cid2Str cid) - void $ mlscli cid ["init", cid2Str cid] Nothing + void $ mlscli cid ["init", "--ciphersuite", mls.ciphersuite.code, cid2Str cid] Nothing -- | Create new mls client and register with backend. createMLSClient :: (MakesValue u, HasCallStack) => u -> App ClientIdentity @@ -160,7 +167,8 @@ uploadNewKeyPackage cid = do generateKeyPackage :: HasCallStack => ClientIdentity -> App (ByteString, String) generateKeyPackage cid = do - kp <- mlscli cid ["key-package", "create"] Nothing + mls <- getMLSState + kp <- mlscli cid ["key-package", "create", "--ciphersuite", mls.ciphersuite.code] Nothing ref <- B8.unpack . Base64.encode <$> mlscli cid ["key-package", "ref", "-"] (Just kp) fp <- keyPackageFile cid ref liftIO $ BS.writeFile fp kp @@ -217,17 +225,21 @@ resetGroup cid conv = do resetClientGroup :: ClientIdentity -> String -> App () resetClientGroup cid gid = do removalKeyPath <- asks (.removalKeyPath) - groupJSON <- + mls <- getMLSState + void $ mlscli cid [ "group", "create", "--removal-key", removalKeyPath, + "--group-out", + "", + "--ciphersuite", + mls.ciphersuite.code, gid ] Nothing - setClientGroupState cid groupJSON keyPackageFile :: HasCallStack => ClientIdentity -> String -> App FilePath keyPackageFile cid ref = do @@ -260,8 +272,9 @@ unbundleKeyPackages bundle = do -- group to the previous state by using an older version of the group file. createAddCommit :: HasCallStack => ClientIdentity -> [Value] -> App MessagePackage createAddCommit cid users = do + mls <- getMLSState kps <- fmap concat . for users $ \user -> do - bundle <- claimKeyPackages cid user >>= getJSON 200 + bundle <- claimKeyPackages mls.ciphersuite cid user >>= getJSON 200 unbundleKeyPackages bundle createAddCommitWithKeyPackages cid kps @@ -325,7 +338,10 @@ createRemoveCommit cid targets = do welcomeFile <- liftIO $ emptyTempFile bd "welcome" giFile <- liftIO $ emptyTempFile bd "gi" - groupStateMap <- Map.fromList <$> (getClientGroupState cid >>= readGroupState) + groupStateMap <- do + gs <- getClientGroupState cid + groupData <- assertJust "Group state not initialised" gs.group + Map.fromList <$> readGroupState groupData let indices = map (fromMaybe (error "could not find target") . flip Map.lookup groupStateMap) targets commit <- @@ -359,10 +375,26 @@ createRemoveCommit cid targets = do createAddProposals :: HasCallStack => ClientIdentity -> [Value] -> App [MessagePackage] createAddProposals cid users = do - bundles <- for users $ (claimKeyPackages cid >=> getJSON 200) + mls <- getMLSState + bundles <- for users $ (claimKeyPackages mls.ciphersuite cid >=> getJSON 200) kps <- concat <$> traverse unbundleKeyPackages bundles traverse (createAddProposalWithKeyPackage cid) kps +createReInitProposal :: HasCallStack => ClientIdentity -> App MessagePackage +createReInitProposal cid = do + prop <- + mlscli + cid + ["proposal", "--group-in", "", "--group-out", "", "re-init"] + Nothing + pure + MessagePackage + { sender = cid, + message = prop, + welcome = Nothing, + groupInfo = Nothing + } + createAddProposalWithKeyPackage :: ClientIdentity -> (ClientIdentity, ByteString) -> @@ -503,8 +535,10 @@ consumeWelcome :: HasCallStack => ByteString -> App () consumeWelcome welcome = do mls <- getMLSState for_ mls.newMembers $ \cid -> do - hasState <- hasClientGroupState cid - assertBool "Existing clients in a conversation should not consume welcomes" (not hasState) + gs <- getClientGroupState cid + assertBool + "Existing clients in a conversation should not consume welcomes" + (isNothing gs.group) void $ mlscli cid @@ -546,19 +580,12 @@ spawn cp minput = do (Just out, ExitSuccess) -> pure out _ -> assertFailure "Failed spawning process" -hasClientGroupState :: HasCallStack => ClientIdentity -> App Bool -hasClientGroupState cid = do - mls <- getMLSState - pure $ Map.member cid mls.clientGroupState - -getClientGroupState :: HasCallStack => ClientIdentity -> App ByteString +getClientGroupState :: HasCallStack => ClientIdentity -> App ClientGroupState getClientGroupState cid = do mls <- getMLSState - case Map.lookup cid mls.clientGroupState of - Nothing -> assertFailure ("Attempted to get non-existing group state for client " <> cid2Str cid) - Just g -> pure g + pure $ Map.findWithDefault emptyClientGroupState cid mls.clientGroupState -setClientGroupState :: HasCallStack => ClientIdentity -> ByteString -> App () +setClientGroupState :: HasCallStack => ClientIdentity -> ClientGroupState -> App () setClientGroupState cid g = modifyMLSState $ \s -> s {clientGroupState = Map.insert cid g (clientGroupState s)} @@ -607,3 +634,6 @@ createApplicationMessage cid messageContent = do welcome = Nothing, groupInfo = Nothing } + +setMLSCiphersuite :: Ciphersuite -> App () +setMLSCiphersuite suite = modifyMLSState $ \mls -> mls {ciphersuite = suite} diff --git a/integration/test/Test/MLS.hs b/integration/test/Test/MLS.hs index 2d333f4087..bbab5479dc 100644 --- a/integration/test/Test/MLS.hs +++ b/integration/test/Test/MLS.hs @@ -203,7 +203,7 @@ testMixedProtocolAddPartialClients secondDomain = do -- create add commit for only one of bob's two clients do - bundle <- claimKeyPackages alice1 bob >>= getJSON 200 + bundle <- claimKeyPackages def alice1 bob >>= getJSON 200 kps <- unbundleKeyPackages bundle kp1 <- assertOne (filter ((== bob1) . fst) kps) mp <- createAddCommitWithKeyPackages alice1 [kp1] @@ -212,7 +212,7 @@ testMixedProtocolAddPartialClients secondDomain = do -- this tests that bob's backend has a mapping of group id to the remote conv -- this test is only interesting when bob is on OtherDomain do - bundle <- claimKeyPackages bob1 bob >>= getJSON 200 + bundle <- claimKeyPackages def bob1 bob >>= getJSON 200 kps <- unbundleKeyPackages bundle kp2 <- assertOne (filter ((== bob2) . fst) kps) mp <- createAddCommitWithKeyPackages bob1 [kp2] @@ -311,14 +311,13 @@ testMLSProtocolUpgrade secondDomain = do resp.status `shouldMatchInt` 200 resp.json %. "protocol" `shouldMatch` "mls" -testAddUser :: HasCallStack => App () -testAddUser = do - [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] +testAddUserSimple :: HasCallStack => Ciphersuite -> App () +testAddUserSimple suite = do + setMLSCiphersuite suite + [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] [alice1, bob1, bob2] <- traverse createMLSClient [alice, bob, bob] - traverse_ uploadNewKeyPackage [bob1, bob2] - (_, qcnv) <- createNewGroup alice1 resp <- createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle @@ -378,8 +377,9 @@ testRemoteRemoveClient = do msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexBob msg %. "message.content.sender.External" `shouldMatchInt` 0 -testCreateSubConv :: HasCallStack => App () -testCreateSubConv = do +testCreateSubConv :: HasCallStack => Ciphersuite -> App () +testCreateSubConv suite = do + setMLSCiphersuite suite alice <- randomUser OwnDomain def alice1 <- createMLSClient alice (_, conv) <- createNewGroup alice1 @@ -499,7 +499,7 @@ testFirstCommitAllowsPartialAdds = do (_, _qcnv) <- createNewGroup alice1 - bundle <- claimKeyPackages alice1 alice >>= getJSON 200 + bundle <- claimKeyPackages def alice1 alice >>= getJSON 200 kps <- unbundleKeyPackages bundle -- first commit only adds kp for alice2 (not alice2 and alice3) @@ -525,7 +525,7 @@ testAddUserPartial = do -- alice sends a commit now, and should get a conflict error kps <- fmap concat . for [bob, charlie] $ \user -> do - bundle <- claimKeyPackages alice1 user >>= getJSON 200 + bundle <- claimKeyPackages def alice1 user >>= getJSON 200 unbundleKeyPackages bundle mp <- createAddCommitWithKeyPackages alice1 kps @@ -607,3 +607,149 @@ testLocalWelcome = do event %. "conversation" `shouldMatch` objId qcnv addedUser <- (event %. "data.users") >>= asList >>= assertOne objQid addedUser `shouldMatch` objQid bob + +testStaleCommit :: HasCallStack => App () +testStaleCommit = do + (alice : users) <- createAndConnectUsers (replicate 5 OwnDomain) + let (users1, users2) = splitAt 2 users + + (alice1 : clients) <- traverse createMLSClient (alice : users) + traverse_ uploadNewKeyPackage clients + void $ createNewGroup alice1 + + gsBackup <- getClientGroupState alice1 + + -- add the first batch of users to the conversation + void $ createAddCommit alice1 users1 >>= sendAndConsumeCommitBundle + + -- now roll back alice1 and try to add the second batch of users + setClientGroupState alice1 gsBackup + + mp <- createAddCommit alice1 users2 + bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "label" `shouldMatch` "mls-stale-message" + +testPropInvalidEpoch :: HasCallStack => App () +testPropInvalidEpoch = do + users@[_alice, bob, charlie, dee] <- createAndConnectUsers (replicate 4 OwnDomain) + [alice1, bob1, charlie1, dee1] <- traverse createMLSClient users + void $ createNewGroup alice1 + + -- Add bob -> epoch 1 + void $ uploadNewKeyPackage bob1 + gsBackup <- getClientGroupState alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + gsBackup2 <- getClientGroupState alice1 + + -- try to send a proposal from an old epoch (0) + do + setClientGroupState alice1 gsBackup + void $ uploadNewKeyPackage dee1 + [prop] <- createAddProposals alice1 [dee] + bindResponse (postMLSMessage alice1 prop.message) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "label" `shouldMatch` "mls-stale-message" + + -- try to send a proposal from a newer epoch (2) + do + void $ uploadNewKeyPackage dee1 + void $ uploadNewKeyPackage charlie1 + setClientGroupState alice1 gsBackup2 + void $ createAddCommit alice1 [charlie] -- --> epoch 2 + [prop] <- createAddProposals alice1 [dee] + bindResponse (postMLSMessage alice1 prop.message) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "label" `shouldMatch` "mls-stale-message" + -- remove charlie from users expected to get a welcome message + modifyMLSState $ \mls -> mls {newMembers = mempty} + + -- alice send a well-formed proposal and commits it + void $ uploadNewKeyPackage dee1 + setClientGroupState alice1 gsBackup2 + createAddProposals alice1 [dee] >>= traverse_ sendAndConsumeMessage + void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + +--- | This test submits a ReInit proposal, which is currently ignored by the +-- backend, in order to check that unsupported proposal types are accepted. +testPropUnsupported :: HasCallStack => App () +testPropUnsupported = do + users@[_alice, bob] <- createAndConnectUsers (replicate 2 OwnDomain) + [alice1, bob1] <- traverse createMLSClient users + void $ uploadNewKeyPackage bob1 + void $ createNewGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + mp <- createReInitProposal alice1 + + -- we cannot consume this message, because the membership tag is fake + void $ postMLSMessage mp.sender mp.message >>= getJSON 201 + +testAddUserBareProposalCommit :: HasCallStack => App () +testAddUserBareProposalCommit = do + [alice, bob] <- createAndConnectUsers (replicate 2 OwnDomain) + [alice1, bob1] <- traverse createMLSClient [alice, bob] + (_, qcnv) <- createNewGroup alice1 + void $ uploadNewKeyPackage bob1 + void $ createAddCommit alice1 [] >>= sendAndConsumeCommitBundle + + createAddProposals alice1 [bob] + >>= traverse_ sendAndConsumeMessage + commit <- createPendingProposalCommit alice1 + void $ assertJust "Expected welcome" commit.welcome + void $ sendAndConsumeCommitBundle commit + + -- check that bob can now see the conversation + convs <- getAllConvs bob + convIds <- traverse (%. "qualified_id") convs + void $ + assertBool + "Users added to an MLS group should find it when listing conversations" + (qcnv `elem` convIds) + +testPropExistingConv :: HasCallStack => App () +testPropExistingConv = do + [alice, bob] <- createAndConnectUsers (replicate 2 OwnDomain) + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + void $ createNewGroup alice1 + void $ createAddCommit alice1 [] >>= sendAndConsumeCommitBundle + res <- createAddProposals alice1 [bob] >>= traverse sendAndConsumeMessage >>= assertOne + shouldBeEmpty (res %. "events") + +testCommitNotReferencingAllProposals :: HasCallStack => App () +testCommitNotReferencingAllProposals = do + users@[_alice, bob, charlie] <- createAndConnectUsers (replicate 3 OwnDomain) + + [alice1, bob1, charlie1] <- traverse createMLSClient users + void $ createNewGroup alice1 + traverse_ uploadNewKeyPackage [bob1, charlie1] + void $ createAddCommit alice1 [] >>= sendAndConsumeCommitBundle + + gsBackup <- getClientGroupState alice1 + + -- create proposals for bob and charlie + createAddProposals alice1 [bob, charlie] + >>= traverse_ sendAndConsumeMessage + + -- now create a commit referencing only the first proposal + setClientGroupState alice1 gsBackup + commit <- createPendingProposalCommit alice1 + + -- send commit and expect and error + bindResponse (postMLSCommitBundle alice1 (mkBundle commit)) $ \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "mls-commit-missing-references" + +testUnsupportedCiphersuite :: HasCallStack => App () +testUnsupportedCiphersuite = do + setMLSCiphersuite (Ciphersuite "0x0002") + alice <- randomUser OwnDomain def + alice1 <- createMLSClient alice + void $ createNewGroup alice1 + + mp <- createPendingProposalCommit alice1 + + bindResponse (postMLSCommitBundle alice1 (mkBundle mp)) $ \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "mls-protocol-error" diff --git a/integration/test/Test/MLS/KeyPackage.hs b/integration/test/Test/MLS/KeyPackage.hs index 5ec25410f6..40760527d1 100644 --- a/integration/test/Test/MLS/KeyPackage.hs +++ b/integration/test/Test/MLS/KeyPackage.hs @@ -16,6 +16,43 @@ testDeleteKeyPackages = do bindResponse (deleteKeyPackages alice1 kps') $ \resp -> do resp.status `shouldMatchInt` 201 - bindResponse (countKeyPackages alice1) $ \resp -> do + + bindResponse (countKeyPackages def alice1) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "count" `shouldMatchInt` 0 + +testKeyPackageMultipleCiphersuites :: App () +testKeyPackageMultipleCiphersuites = do + alice <- randomUser OwnDomain def + [alice1, alice2] <- replicateM 2 (createMLSClient alice) + + kp <- uploadNewKeyPackage alice2 + + let suite = Ciphersuite "0xf031" + setMLSCiphersuite suite + void $ uploadNewKeyPackage alice2 + + -- count key packages with default ciphersuite + bindResponse (countKeyPackages def alice2) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "count" `shouldMatchInt` 1 + + -- claim key packages with default ciphersuite + bindResponse (claimKeyPackages def alice1 alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "key_packages.0.key_package_ref" `shouldMatch` kp + + -- count key package with the other ciphersuite + bindResponse (countKeyPackages suite alice2) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "count" `shouldMatchInt` 1 + +testUnsupportedCiphersuite :: HasCallStack => App () +testUnsupportedCiphersuite = do + setMLSCiphersuite (Ciphersuite "0x0002") + bob <- randomUser OwnDomain def + bob1 <- createMLSClient bob + (kp, _) <- generateKeyPackage bob1 + bindResponse (uploadKeyPackage bob1 kp) $ \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "mls-protocol-error" diff --git a/integration/test/Testlib/Env.hs b/integration/test/Testlib/Env.hs index d0a6a54185..7df880fca0 100644 --- a/integration/test/Testlib/Env.hs +++ b/integration/test/Testlib/Env.hs @@ -6,6 +6,7 @@ import Control.Monad.Codensity import Control.Monad.IO.Class import Data.Aeson hiding ((.=)) import Data.ByteString (ByteString) +import Data.Default import Data.Functor import Data.IORef import Data.Map (Map) @@ -224,6 +225,24 @@ create ioRef = Nothing -> error "No resources available" Just (r, s') -> (s', r) +data ClientGroupState = ClientGroupState + { group :: Maybe ByteString, + keystore :: Maybe ByteString + } + deriving (Show) + +emptyClientGroupState :: ClientGroupState +emptyClientGroupState = ClientGroupState Nothing Nothing + +newtype Ciphersuite = Ciphersuite {code :: String} + deriving (Eq, Ord, Show) + +instance Default Ciphersuite where + def = Ciphersuite "0x0001" + +allCiphersuites :: [Ciphersuite] +allCiphersuites = map Ciphersuite ["0x0001", "0xf031"] + data MLSState = MLSState { baseDir :: FilePath, members :: Set ClientIdentity, @@ -231,8 +250,9 @@ data MLSState = MLSState newMembers :: Set ClientIdentity, groupId :: Maybe String, convId :: Maybe Value, - clientGroupState :: Map ClientIdentity ByteString, - epoch :: Word64 + clientGroupState :: Map ClientIdentity ClientGroupState, + epoch :: Word64, + ciphersuite :: Ciphersuite } deriving (Show) @@ -247,7 +267,8 @@ mkMLSState = Codensity $ \k -> groupId = Nothing, convId = Nothing, clientGroupState = mempty, - epoch = 0 + epoch = 0, + ciphersuite = def } data ClientIdentity = ClientIdentity diff --git a/integration/test/Testlib/PTest.hs b/integration/test/Testlib/PTest.hs index d2613fa214..1aa478b720 100644 --- a/integration/test/Testlib/PTest.hs +++ b/integration/test/Testlib/PTest.hs @@ -1,6 +1,7 @@ module Testlib.PTest where import Testlib.App +import Testlib.Env import Testlib.Types import Prelude @@ -16,3 +17,10 @@ instance HasTests x => HasTests (Domain -> x) where mkTests m n s f x = mkTests m (n <> "[domain=own]") s f (x OwnDomain) <> mkTests m (n <> "[domain=other]") s f (x OtherDomain) + +instance HasTests x => HasTests (Ciphersuite -> x) where + mkTests m n s f x = + mconcat + [ mkTests m (n <> "[suite=" <> suite.code <> "]") s f (x suite) + | suite <- allCiphersuites + ] diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs b/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs index f663444419..c2a25af65b 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs @@ -97,7 +97,7 @@ newtype GetUserClients = GetUserClients data MLSClientsRequest = MLSClientsRequest { userId :: UserId, -- implicitly qualified by the local domain - signatureScheme :: SignatureSchemeTag + cipherSuite :: CipherSuite } deriving stock (Eq, Show, Generic) deriving (ToJSON, FromJSON) via (CustomEncoded MLSClientsRequest) @@ -160,7 +160,9 @@ data ClaimKeyPackageRequest = ClaimKeyPackageRequest claimant :: UserId, -- | The user whose key packages are being claimed, implictly qualified by -- the target domain. - target :: UserId + target :: UserId, + -- | The ciphersuite of the key packages being claimed. + cipherSuite :: CipherSuite } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform ClaimKeyPackageRequest) diff --git a/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs b/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs index f6da30927e..339f00f012 100644 --- a/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs +++ b/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs @@ -20,6 +20,7 @@ module Wire.API.MLS.CipherSuite ( -- * MLS ciphersuites CipherSuite (..), + defCipherSuite, CipherSuiteTag (..), cipherSuiteTag, tagCipherSuite, @@ -50,6 +51,7 @@ import Crypto.PubKey.Ed25519 qualified as Ed25519 import Data.Aeson qualified as Aeson import Data.Aeson.Types (FromJSON (..), FromJSONKey (..), ToJSON (..), ToJSONKey (..)) import Data.Aeson.Types qualified as Aeson +import Data.Bifunctor import Data.ByteArray hiding (index) import Data.ByteArray qualified as BA import Data.Proxy @@ -57,25 +59,55 @@ import Data.Schema import Data.Swagger qualified as S import Data.Swagger.Internal.Schema qualified as S import Data.Text qualified as T +import Data.Text.Lazy qualified as LT +import Data.Text.Lazy.Builder qualified as LT +import Data.Text.Lazy.Builder.Int qualified as LT +import Data.Text.Read qualified as T import Data.Word import Imports hiding (cs) -import Servant (FromHttpApiData (parseQueryParam)) +import Web.HttpApiData import Wire.API.MLS.Serialisation import Wire.Arbitrary newtype CipherSuite = CipherSuite {cipherSuiteNumber :: Word16} deriving stock (Eq, Show) deriving newtype (ParseMLS, SerialiseMLS, Arbitrary) + deriving (FromJSON, ToJSON) via Schema CipherSuite instance ToSchema CipherSuite where schema = named "CipherSuite" $ cipherSuiteNumber .= fmap CipherSuite (unnamed schema) -data CipherSuiteTag = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 +instance S.ToParamSchema CipherSuite where + toParamSchema _ = + mempty + & S.type_ ?~ S.SwaggerNumber + +instance FromHttpApiData CipherSuite where + parseUrlPiece t = do + (x, rest) <- first T.pack $ T.hexadecimal t + unless (T.null rest) $ + Left "Trailing characters after ciphersuite number" + pure (CipherSuite x) + +instance ToHttpApiData CipherSuite where + toUrlPiece = + LT.toStrict + . LT.toLazyText + . ("0x" <>) + . LT.hexadecimal + . cipherSuiteNumber + +data CipherSuiteTag + = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + | MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 deriving stock (Bounded, Enum, Eq, Show, Generic, Ord) deriving (Arbitrary) via (GenericUniform CipherSuiteTag) +defCipherSuite :: CipherSuiteTag +defCipherSuite = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + instance S.ToSchema CipherSuiteTag where declareNamedSchema _ = pure . S.named "CipherSuiteTag" $ @@ -99,20 +131,29 @@ instance ToSchema CipherSuiteTag where -- | See https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#table-5. cipherSuiteTag :: CipherSuite -> Maybe CipherSuiteTag -cipherSuiteTag (CipherSuite n) = case n of - 1 -> pure MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 - _ -> Nothing +cipherSuiteTag cs = listToMaybe $ do + t <- [minBound .. maxBound] + guard (tagCipherSuite t == cs) + pure t -- | Inverse of 'cipherSuiteTag' tagCipherSuite :: CipherSuiteTag -> CipherSuite tagCipherSuite MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = CipherSuite 1 +tagCipherSuite MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 = CipherSuite 0xf031 csHash :: CipherSuiteTag -> ByteString -> RawMLS a -> ByteString -csHash MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 ctx value = - convert . hashWith SHA256 . encodeMLS' $ RefHashInput ctx value +csHash MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = sha256Hash +csHash MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 = sha256Hash + +sha256Hash :: ByteString -> RawMLS a -> ByteString +sha256Hash ctx value = convert . hashWith SHA256 . encodeMLS' $ RefHashInput ctx value csVerifySignature :: CipherSuiteTag -> ByteString -> RawMLS a -> ByteString -> Bool -csVerifySignature MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 pub x sig = +csVerifySignature MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = ed25519VerifySignature +csVerifySignature MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 = ed25519VerifySignature + +ed25519VerifySignature :: ByteString -> RawMLS a -> ByteString -> Bool +ed25519VerifySignature pub x sig = fromMaybe False . maybeCryptoError $ do pub' <- Ed25519.publicKey pub sig' <- Ed25519.signature sig @@ -158,6 +199,7 @@ signWithLabel sigLabel priv pub x = BA.convert $ Ed25519.sign priv pub (encodeML csSignatureScheme :: CipherSuiteTag -> SignatureSchemeTag csSignatureScheme MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = Ed25519 +csSignatureScheme MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 = Ed25519 -- | A TLS signature scheme. -- diff --git a/libs/wire-api/src/Wire/API/MLS/Validation.hs b/libs/wire-api/src/Wire/API/MLS/Validation.hs index f4fd60c56e..eadc3442f2 100644 --- a/libs/wire-api/src/Wire/API/MLS/Validation.hs +++ b/libs/wire-api/src/Wire/API/MLS/Validation.hs @@ -23,6 +23,9 @@ module Wire.API.MLS.Validation where import Control.Applicative +import Data.Text.Lazy qualified as LT +import Data.Text.Lazy.Builder qualified as LT +import Data.Text.Lazy.Builder.Int qualified as LT import Imports hiding (cs) import Wire.API.MLS.Capabilities import Wire.API.MLS.CipherSuite @@ -41,7 +44,11 @@ validateKeyPackage mIdentity kp = do -- get ciphersuite cs <- maybe - (Left "Unsupported ciphersuite") + ( Left + ( "Unsupported ciphersuite 0x" + <> LT.toStrict (LT.toLazyText (LT.hexadecimal kp.cipherSuite.cipherSuiteNumber)) + ) + ) pure $ cipherSuiteTag kp.cipherSuite diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index b296cb919a..241864cfe8 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -54,7 +54,7 @@ import Servant.Swagger.Internal.Orphans () import Wire.API.Connection import Wire.API.Error import Wire.API.Error.Brig -import Wire.API.MLS.CipherSuite (SignatureSchemeTag) +import Wire.API.MLS.CipherSuite import Wire.API.MakesFederatedCall import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Brig.Connection @@ -64,7 +64,7 @@ import Wire.API.Routes.Internal.Brig.SearchIndex (ISearchIndexAPI) import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti qualified as Multi import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named -import Wire.API.Routes.Public (ZUser {- yes, this is a bit weird -}) +import Wire.API.Routes.Public (ZUser) import Wire.API.Team.Feature import Wire.API.Team.LegalHold.Internal import Wire.API.User @@ -500,7 +500,7 @@ type GetMLSClients = :> "clients" :> CanThrow 'UserNotFound :> Capture "user" UserId - :> QueryParam' '[Required, Strict] "sig_scheme" SignatureSchemeTag + :> QueryParam' '[Required, Strict] "ciphersuite" CipherSuite :> MultiVerb1 'GET '[Servant.JSON] diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index 7550b5bd60..9dab023107 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -45,6 +45,7 @@ import Wire.API.Connection hiding (MissingLegalholdConsent) import Wire.API.Error import Wire.API.Error.Brig import Wire.API.Error.Empty +import Wire.API.MLS.CipherSuite import Wire.API.MLS.KeyPackage import Wire.API.MLS.Servant import Wire.API.MakesFederatedCall @@ -1098,6 +1099,8 @@ type ConnectionAPI = :> Get '[Servant.JSON] (SearchResult Contact) ) +-- Properties API ----------------------------------------------------- + type PropertiesAPI = LiftNamed ( ZUser @@ -1158,7 +1161,16 @@ type PropertiesAPI = :> Get '[JSON] PropertyKeysAndValues ) --- Properties API ----------------------------------------------------- +-- MLS API --------------------------------------------------------------------- + +type CipherSuiteParam = + QueryParam' + [ Optional, + Strict, + Description "Ciphersuite in hex format (e.g. 0xf031) - default is 0x0001" + ] + "ciphersuite" + CipherSuite type MLSKeyPackageAPI = "key-packages" @@ -1178,25 +1190,21 @@ type MLSKeyPackageAPI = "mls-key-packages-claim" ( "claim" :> Summary "Claim one key package for each client of the given user" - :> MakesFederatedCall 'Brig "claim-key-packages" + :> Description "Only key packages for the specified ciphersuite are claimed. For backwards compatibility, the `ciphersuite` parameter is optional, defaulting to ciphersuite 0x0001 when omitted." :> ZLocalUser + :> ZOptClient :> QualifiedCaptureUserId "user" - :> QueryParam' - [ Optional, - Strict, - Description "Do not claim a key package for the given own client" - ] - "skip_own" - ClientId + :> CipherSuiteParam :> MultiVerb1 'POST '[JSON] (Respond 200 "Claimed key packages" KeyPackageBundle) ) :<|> Named "mls-key-packages-count" ( "self" + :> Summary "Return the number of unclaimed key packages for a given ciphersuite and client" :> ZLocalUser :> CaptureClientId "client" :> "count" - :> Summary "Return the number of unused key packages for the given client" + :> CipherSuiteParam :> MultiVerb1 'GET '[JSON] (Respond 200 "Number of key packages" KeyPackageCount) ) :<|> Named @@ -1204,7 +1212,8 @@ type MLSKeyPackageAPI = ( "self" :> ZLocalUser :> CaptureClientId "client" - :> Summary "Return the number of unused key packages for the given client" + :> Summary "Delete all key packages for a given ciphersuite and client" + :> CipherSuiteParam :> ReqBody '[JSON] DeleteKeyPackages :> MultiVerb1 'DELETE '[JSON] (RespondEmpty 201 "OK") ) diff --git a/nix/pkgs/mls-test-cli/default.nix b/nix/pkgs/mls-test-cli/default.nix index e7104041ed..1e38ca6039 100644 --- a/nix/pkgs/mls-test-cli/default.nix +++ b/nix/pkgs/mls-test-cli/default.nix @@ -13,8 +13,8 @@ let src = fetchFromGitHub { owner = "wireapp"; repo = "mls-test-cli"; - rev = "87845faa7d5ee69652747ceaf1664baa8198c0d8"; - sha256 = "sha256-DoQ6brp1KvglVVCDp4vC5zaRx76IUywu3Rcu/TzJlvo="; + rev = "cc815d71a1d9485265b7ae158daf7b27badedee6"; + sha256 = "sha256-CJoc20pOtsxAQNCA3qhv8NtPbzZ4yCIMvuhlgcqPrds="; }; cargoLockFile = builtins.toFile "cargo.lock" (builtins.readFile "${src}/Cargo.lock"); in rustPlatform.buildRustPackage rec { @@ -24,8 +24,10 @@ in rustPlatform.buildRustPackage rec { cargoLock = { lockFile = cargoLockFile; outputHashes = { - "hpke-0.10.0" = "sha256-XYkG72ZeQ3nM4JjgNU5Fe0HqNGkBGcI70rE1Kbz/6vs="; - "openmls-0.20.0" = "sha256-i5xNTYP1wPzwlnqz+yPu8apKCibRZacz4OV5VVZwY5Y="; + "hpke-0.10.0" = "sha256-6zyTb2c2DU4mXn9vRQe+lXNaeQ3JOVUz+BS15Xb2E+Y="; + "openmls-0.20.2" = "sha256-QgQb5Ts8TB2nwfxMss4qHCz096ijMXBxyq7q2ITyEGg="; + "safe_pqc_kyber-0.6.0" = "sha256-Ch1LA+by+ezf5RV0LDSQGC1o+IWKXk8IPvkwSrAos68="; + "tls_codec-0.3.0" = "sha256-IO6tenXKkC14EoUDp/+DtFNOVzDfOlLu8K1EJI7sOzs="; }; }; diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 93b775db4a..5e067fcbba 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -82,6 +82,7 @@ library Brig.API.Federation Brig.API.Handler Brig.API.Internal + Brig.API.MLS.CipherSuite Brig.API.MLS.KeyPackages Brig.API.MLS.KeyPackages.Validation Brig.API.MLS.Util @@ -537,6 +538,7 @@ executable brig-schema V77_FederationRemotes V78_ClientLastActive V79_ConnectionRemoteIndex + V80_KeyPackageCiphersuite V_FUTUREWORK hs-source-dirs: schema/src diff --git a/services/brig/schema/src/Run.hs b/services/brig/schema/src/Run.hs index c9423fd653..0f1b71127b 100644 --- a/services/brig/schema/src/Run.hs +++ b/services/brig/schema/src/Run.hs @@ -59,6 +59,7 @@ import V76_AddSupportedProtocols qualified import V77_FederationRemotes qualified import V78_ClientLastActive qualified import V79_ConnectionRemoteIndex qualified +import V80_KeyPackageCiphersuite qualified main :: IO () main = do @@ -105,7 +106,8 @@ main = do V76_AddSupportedProtocols.migration, V77_FederationRemotes.migration, V78_ClientLastActive.migration, - V79_ConnectionRemoteIndex.migration + V79_ConnectionRemoteIndex.migration, + V80_KeyPackageCiphersuite.migration -- When adding migrations here, don't forget to update -- 'schemaVersion' in Brig.App diff --git a/services/brig/schema/src/V80_KeyPackageCiphersuite.hs b/services/brig/schema/src/V80_KeyPackageCiphersuite.hs new file mode 100644 index 0000000000..7e7ec8b21d --- /dev/null +++ b/services/brig/schema/src/V80_KeyPackageCiphersuite.hs @@ -0,0 +1,50 @@ +{-# LANGUAGE QuasiQuotes #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 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 V80_KeyPackageCiphersuite + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +-- Index key packages by ciphersuite as well as user and client. + +-- Note: this migration recreates the mls_key_packages table from scratch, and +-- therefore loses all the data it contains. That means clients will need to +-- re-upload key packages after this migration is run. + +migration :: Migration +migration = + Migration 80 "Recreate mls_key_packages table" $ do + schema' [r| DROP TABLE IF EXISTS mls_key_packages; |] + schema' + [r| + CREATE TABLE mls_key_packages + ( user uuid + , client text + , cipher_suite int + , ref blob + , data blob + , PRIMARY KEY ((user, client, cipher_suite), ref) + ) WITH compaction = {'class': 'org.apache.cassandra.db.compaction.LeveledCompactionStrategy'} + AND gc_grace_seconds = 864000; + |] diff --git a/services/brig/src/Brig/API/Federation.hs b/services/brig/src/Brig/API/Federation.hs index c8da1a5e70..f7bdf0f387 100644 --- a/services/brig/src/Brig/API/Federation.hs +++ b/services/brig/src/Brig/API/Federation.hs @@ -25,6 +25,7 @@ import Brig.API.Error import Brig.API.Handler (Handler) import Brig.API.Internal hiding (getMLSClients) import Brig.API.Internal qualified as Internal +import Brig.API.MLS.CipherSuite import Brig.API.MLS.KeyPackages import Brig.API.MLS.Util import Brig.API.User qualified as API @@ -166,10 +167,11 @@ fedClaimKeyPackages :: Domain -> ClaimKeyPackageRequest -> Handler r (Maybe KeyP fedClaimKeyPackages domain ckpr = isMLSEnabled >>= \case True -> do + suite <- getCipherSuite (Just ckpr.cipherSuite) ltarget <- qualifyLocal ckpr.target let rusr = toRemoteUnsafe domain ckpr.claimant lift . fmap hush . runExceptT $ - claimLocalKeyPackages (tUntagged rusr) Nothing ltarget + claimLocalKeyPackages (tUntagged rusr) Nothing suite ltarget False -> pure Nothing -- | Searching for federated users on a remote backend should @@ -220,7 +222,7 @@ getUserClients _ (GetUserClients uids) = API.lookupLocalPubClientsBulk uids !>> getMLSClients :: Domain -> MLSClientsRequest -> Handler r (Set ClientInfo) getMLSClients _domain mcr = do - Internal.getMLSClients mcr.userId mcr.signatureScheme + Internal.getMLSClients mcr.userId mcr.cipherSuite onUserDeleted :: Domain -> UserDeletedConnectionsNotification -> (Handler r) EmptyResponse onUserDeleted origDomain udcn = lift $ do diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 71a691640b..306ea0858c 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -28,6 +28,7 @@ import Brig.API.Client qualified as API import Brig.API.Connection qualified as API import Brig.API.Error import Brig.API.Handler +import Brig.API.MLS.KeyPackages.Validation import Brig.API.OAuth (internalOauthAPI) import Brig.API.Types import Brig.API.User qualified as API @@ -384,12 +385,12 @@ deleteAccountConferenceCallingConfig :: UserId -> (Handler r) NoContent deleteAccountConferenceCallingConfig uid = lift $ wrapClient $ Data.updateFeatureConferenceCalling uid Nothing $> NoContent -getMLSClients :: UserId -> SignatureSchemeTag -> Handler r (Set ClientInfo) -getMLSClients usr _ss = do - -- FUTUREWORK: check existence of key packages with a given ciphersuite +getMLSClients :: UserId -> CipherSuite -> Handler r (Set ClientInfo) +getMLSClients usr suite = do lusr <- qualifyLocal usr + suiteTag <- maybe (mlsProtocolError "Unknown ciphersuite") pure (cipherSuiteTag suite) allClients <- lift (wrapClient (API.lookupUsersClientIds (pure usr))) >>= getResult - clientInfo <- lift . wrapClient $ pooledMapConcurrentlyN 16 (getValidity lusr) (toList allClients) + clientInfo <- lift . wrapClient $ pooledMapConcurrentlyN 16 (\c -> getValidity lusr c suiteTag) (toList allClients) pure . Set.fromList . map (uncurry ClientInfo) $ clientInfo where getResult [] = pure mempty @@ -397,9 +398,9 @@ getMLSClients usr _ss = do | u == usr = pure cs' | otherwise = getResult rs - getValidity lusr cid = + getValidity lusr cid suiteTag = (cid,) . (> 0) - <$> Data.countKeyPackages lusr cid + <$> Data.countKeyPackages lusr cid suiteTag getVerificationCode :: UserId -> VerificationAction -> Handler r (Maybe Code.Value) getVerificationCode uid action = do diff --git a/services/brig/src/Brig/API/MLS/CipherSuite.hs b/services/brig/src/Brig/API/MLS/CipherSuite.hs new file mode 100644 index 0000000000..ec6b975678 --- /dev/null +++ b/services/brig/src/Brig/API/MLS/CipherSuite.hs @@ -0,0 +1,29 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 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 Brig.API.MLS.CipherSuite (getCipherSuite) where + +import Brig.API.Handler +import Brig.API.MLS.KeyPackages.Validation +import Imports +import Wire.API.MLS.CipherSuite + +getCipherSuite :: Maybe CipherSuite -> Handler r CipherSuiteTag +getCipherSuite mSuite = case mSuite of + Nothing -> pure defCipherSuite + Just x -> + maybe (mlsProtocolError "Unknown ciphersuite") pure (cipherSuiteTag x) diff --git a/services/brig/src/Brig/API/MLS/KeyPackages.hs b/services/brig/src/Brig/API/MLS/KeyPackages.hs index ce8b1ad764..4a3c244b35 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages.hs @@ -26,6 +26,7 @@ where import Brig.API.Error import Brig.API.Handler +import Brig.API.MLS.CipherSuite import Brig.API.MLS.KeyPackages.Validation import Brig.API.MLS.Util import Brig.API.Types @@ -42,6 +43,7 @@ import Data.Set qualified as Set import Imports import Wire.API.Federation.API import Wire.API.Federation.API.Brig +import Wire.API.MLS.CipherSuite import Wire.API.MLS.Credential import Wire.API.MLS.KeyPackage import Wire.API.MLS.Serialisation @@ -57,23 +59,26 @@ uploadKeyPackages lusr cid kps = do claimKeyPackages :: Local UserId -> - Qualified UserId -> Maybe ClientId -> + Qualified UserId -> + Maybe CipherSuite -> Handler r KeyPackageBundle -claimKeyPackages lusr target skipOwn = do +claimKeyPackages lusr mClient target mSuite = do assertMLSEnabled + suite <- getCipherSuite mSuite foldQualified lusr - (withExceptT clientError . claimLocalKeyPackages (tUntagged lusr) skipOwn) - (claimRemoteKeyPackages lusr) + (withExceptT clientError . claimLocalKeyPackages (tUntagged lusr) mClient suite) + (claimRemoteKeyPackages lusr (tagCipherSuite suite)) target claimLocalKeyPackages :: Qualified UserId -> Maybe ClientId -> + CipherSuiteTag -> Local UserId -> ExceptT ClientError (AppT r) KeyPackageBundle -claimLocalKeyPackages qusr skipOwn target = do +claimLocalKeyPackages qusr skipOwn suite target = do -- skip own client when the target is the requesting user itself let own = guard (qusr == tUntagged target) *> skipOwn clients <- map clientId <$> wrapClientE (Data.lookupClients (tUnqualified target)) @@ -94,13 +99,14 @@ claimLocalKeyPackages qusr skipOwn target = do runMaybeT $ do guard $ Just c /= own uncurry (KeyPackageBundleEntry (tUntagged target) c) - <$> wrapClientM (Data.claimKeyPackage target c) + <$> wrapClientM (Data.claimKeyPackage target c suite) claimRemoteKeyPackages :: Local UserId -> + CipherSuite -> Remote UserId -> Handler r KeyPackageBundle -claimRemoteKeyPackages lusr target = do +claimRemoteKeyPackages lusr suite target = do bundle <- withExceptT clientError . (handleFailure =<<) @@ -109,7 +115,8 @@ claimRemoteKeyPackages lusr target = do $ fedClient @'Brig @"claim-key-packages" $ ClaimKeyPackageRequest { claimant = tUnqualified lusr, - target = tUnqualified target + target = tUnqualified target, + cipherSuite = suite } -- validate all claimed key packages @@ -121,7 +128,7 @@ claimRemoteKeyPackages lusr target = do . decodeMLS' . kpData $ e.keyPackage - (refVal, _) <- validateUploadedKeyPackage cid kpRaw + (refVal, _, _) <- validateUploadedKeyPackage cid kpRaw unless (refVal == e.ref) . throwE . clientDataError @@ -132,18 +139,21 @@ claimRemoteKeyPackages lusr target = do handleFailure :: Monad m => Maybe x -> ExceptT ClientError m x handleFailure = maybe (throwE (ClientUserNotFound (tUnqualified target))) pure -countKeyPackages :: Local UserId -> ClientId -> Handler r KeyPackageCount -countKeyPackages lusr c = do +countKeyPackages :: Local UserId -> ClientId -> Maybe CipherSuite -> Handler r KeyPackageCount +countKeyPackages lusr c mSuite = do assertMLSEnabled + suite <- getCipherSuite mSuite lift $ KeyPackageCount . fromIntegral - <$> wrapClient (Data.countKeyPackages lusr c) + <$> wrapClient (Data.countKeyPackages lusr c suite) deleteKeyPackages :: Local UserId -> ClientId -> + Maybe CipherSuite -> DeleteKeyPackages -> Handler r () -deleteKeyPackages lusr c (unDeleteKeyPackages -> refs) = do +deleteKeyPackages lusr c mSuite (unDeleteKeyPackages -> refs) = do assertMLSEnabled - lift $ wrapClient (Data.deleteKeyPackages (tUnqualified lusr) c refs) + suite <- getCipherSuite mSuite + lift $ wrapClient (Data.deleteKeyPackages (tUnqualified lusr) c suite refs) diff --git a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs index d559d09e9b..0783663e80 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs @@ -47,7 +47,7 @@ import Wire.API.MLS.Validation validateUploadedKeyPackage :: ClientIdentity -> RawMLS KeyPackage -> - Handler r (KeyPackageRef, KeyPackageData) + Handler r (KeyPackageRef, CipherSuiteTag, KeyPackageData) validateUploadedKeyPackage identity kp = do (cs, lt) <- either mlsProtocolError pure $ validateKeyPackage (Just identity) kp.value @@ -77,7 +77,7 @@ validateUploadedKeyPackage identity kp = do (cidQualifiedClient identity) let kpd = KeyPackageData kp.raw - pure (kpRef cs kpd, kpd) + pure (kpRef cs kpd, cs, kpd) validateLifetime :: Lifetime -> Handler r () validateLifetime lt = do diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index dcf59d4b0a..467143eae6 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -384,7 +384,7 @@ servantSitemap = mlsAPI :: ServerT MLSAPI (Handler r) mlsAPI = Named @"mls-key-packages-upload" uploadKeyPackages - :<|> Named @"mls-key-packages-claim" (callsFed (exposeAnnotations claimKeyPackages)) + :<|> Named @"mls-key-packages-claim" claimKeyPackages :<|> Named @"mls-key-packages-count" countKeyPackages :<|> Named @"mls-key-packages-delete" deleteKeyPackages diff --git a/services/brig/src/Brig/Data/Instances.hs b/services/brig/src/Brig/Data/Instances.hs index dfbba99f65..3430931577 100644 --- a/services/brig/src/Brig/Data/Instances.hs +++ b/services/brig/src/Brig/Data/Instances.hs @@ -39,6 +39,7 @@ import Data.Text.Encoding (encodeUtf8) import Imports import Wire.API.Asset (AssetKey, assetKeyToText, nilAssetKey) import Wire.API.Connection (RelationWithHistory (..)) +import Wire.API.MLS.CipherSuite import Wire.API.Properties import Wire.API.User import Wire.API.User.Activation @@ -306,3 +307,13 @@ instance Cql (Imports.Set BaseProtocolTag) where toCql = CqlInt . fromIntegral . protocolSetBits fromCql (CqlInt bits) = pure $ protocolSetFromBits (fromIntegral bits) fromCql _ = Left "Protocol set: Int expected" + +instance Cql CipherSuiteTag where + ctype = Tagged IntColumn + toCql = CqlInt . fromIntegral . cipherSuiteNumber . tagCipherSuite + + fromCql (CqlInt index) = + case cipherSuiteTag (CipherSuite (fromIntegral index)) of + Just tag -> Right tag + Nothing -> Left "CipherSuiteTag: unexpected index" + fromCql _ = Left "CipherSuiteTag: int expected" diff --git a/services/brig/src/Brig/Data/MLS/KeyPackage.hs b/services/brig/src/Brig/Data/MLS/KeyPackage.hs index fc3c4183bc..a03192f32e 100644 --- a/services/brig/src/Brig/Data/MLS/KeyPackage.hs +++ b/services/brig/src/Brig/Data/MLS/KeyPackage.hs @@ -37,19 +37,25 @@ import Data.Qualified import Data.Time.Clock import Data.Time.Clock.POSIX import Imports +import Wire.API.MLS.CipherSuite import Wire.API.MLS.KeyPackage import Wire.API.MLS.LeafNode import Wire.API.MLS.Serialisation -insertKeyPackages :: MonadClient m => UserId -> ClientId -> [(KeyPackageRef, KeyPackageData)] -> m () +insertKeyPackages :: + MonadClient m => + UserId -> + ClientId -> + [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> + m () insertKeyPackages uid cid kps = retry x5 . batch $ do setType BatchLogged setConsistency LocalQuorum - for_ kps $ \(ref, kp) -> do - addPrepQuery q (uid, cid, kp, ref) + for_ kps $ \(ref, suite, kp) -> do + addPrepQuery q (uid, cid, suite, kp, ref) where - q :: PrepQuery W (UserId, ClientId, KeyPackageData, KeyPackageRef) () - q = "INSERT INTO mls_key_packages (user, client, data, ref) VALUES (?, ?, ?, ?)" + q :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageData, KeyPackageRef) () + q = "INSERT INTO mls_key_packages (user, client, cipher_suite, data, ref) VALUES (?, ?, ?, ?, ?)" claimKeyPackage :: ( MonadReader Env m, @@ -58,21 +64,22 @@ claimKeyPackage :: ) => Local UserId -> ClientId -> + CipherSuiteTag -> MaybeT m (KeyPackageRef, KeyPackageData) -claimKeyPackage u c = do +claimKeyPackage u c suite = do -- FUTUREWORK: investigate better locking strategies lock <- lift $ view keyPackageLocalLock -- get a random key package and delete it (ref, kpd) <- MaybeT . withMVar lock . const $ do - kps <- getNonClaimedKeyPackages u c + kps <- getNonClaimedKeyPackages u c suite mk <- liftIO (pick kps) for mk $ \(ref, kpd) -> do - retry x5 $ write delete1Query (params LocalQuorum (tUnqualified u, c, ref)) + retry x5 $ write delete1Query (params LocalQuorum (tUnqualified u, c, suite, ref)) pure (ref, kpd) pure (ref, kpd) where - delete1Query :: PrepQuery W (UserId, ClientId, KeyPackageRef) () - delete1Query = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND ref = ?" + delete1Query :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageRef) () + delete1Query = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref = ?" -- | Fetch all unclaimed non-expired key packages for a given client and delete -- from the database those that have expired. @@ -82,9 +89,10 @@ getNonClaimedKeyPackages :: ) => Local UserId -> ClientId -> + CipherSuiteTag -> m [(KeyPackageRef, KeyPackageData)] -getNonClaimedKeyPackages u c = do - kps <- retry x1 $ query lookupQuery (params LocalQuorum (tUnqualified u, c)) +getNonClaimedKeyPackages u c suite = do + kps <- retry x1 $ query lookupQuery (params LocalQuorum (tUnqualified u, c, suite)) let decodedKps = foldMap (keepDecoded . (decodeKp &&& id)) kps now <- liftIO getPOSIXTime @@ -93,11 +101,11 @@ getNonClaimedKeyPackages u c = do let (kpsExpired, kpsNonExpired) = partition (hasExpired now mMaxLifetime) decodedKps -- delete expired key packages - deleteKeyPackages (tUnqualified u) c (map (\(_, (ref, _)) -> ref) kpsExpired) + deleteKeyPackages (tUnqualified u) c suite (map (\(_, (ref, _)) -> ref) kpsExpired) pure $ fmap snd kpsNonExpired where - lookupQuery :: PrepQuery R (UserId, ClientId) (KeyPackageRef, KeyPackageData) - lookupQuery = "SELECT ref, data FROM mls_key_packages WHERE user = ? AND client = ?" + lookupQuery :: PrepQuery R (UserId, ClientId, CipherSuiteTag) (KeyPackageRef, KeyPackageData) + lookupQuery = "SELECT ref, data FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" decodeKp :: (a, KeyPackageData) -> Maybe KeyPackage decodeKp = hush . decodeMLS' . kpData . snd @@ -120,18 +128,19 @@ countKeyPackages :: ) => Local UserId -> ClientId -> + CipherSuiteTag -> m Int64 -countKeyPackages u c = fromIntegral . length <$> getNonClaimedKeyPackages u c +countKeyPackages u c suite = fromIntegral . length <$> getNonClaimedKeyPackages u c suite -deleteKeyPackages :: MonadClient m => UserId -> ClientId -> [KeyPackageRef] -> m () -deleteKeyPackages u c refs = +deleteKeyPackages :: MonadClient m => UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> m () +deleteKeyPackages u c suite refs = retry x5 $ write deleteQuery - (params LocalQuorum (u, c, refs)) + (params LocalQuorum (u, c, suite, refs)) where - deleteQuery :: PrepQuery W (UserId, ClientId, [KeyPackageRef]) () - deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND ref in ?" + deleteQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, [KeyPackageRef]) () + deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref in ?" -------------------------------------------------------------------------------- -- Utilities diff --git a/services/brig/test/integration/API/Federation.hs b/services/brig/test/integration/API/Federation.hs index 8e0f7624b3..47025e9a6d 100644 --- a/services/brig/test/integration/API/Federation.hs +++ b/services/brig/test/integration/API/Federation.hs @@ -53,6 +53,7 @@ import Wire.API.Federation.API.Brig qualified as FedBrig import Wire.API.Federation.API.Brig qualified as S import Wire.API.Federation.Component import Wire.API.Federation.Version +import Wire.API.MLS.CipherSuite import Wire.API.MLS.KeyPackage import Wire.API.Routes.FederationDomainConfig as FD import Wire.API.User @@ -420,7 +421,10 @@ testClaimKeyPackages brig fedBrigClient = do Just bundle <- runFedClient @"claim-key-packages" fedBrigClient (qDomain alice) $ - ClaimKeyPackageRequest (qUnqualified alice) (qUnqualified bob) + ClaimKeyPackageRequest + (qUnqualified alice) + (qUnqualified bob) + (tagCipherSuite defCipherSuite) liftIO $ Set.map (\e -> (e.user, e.client)) bundle.entries @@ -440,6 +444,9 @@ testClaimKeyPackagesMLSDisabled opts brig = do withSettingsOverrides (opts & Opt.optionSettings . Opt.enableMLS ?~ False) $ runWaiTestFedClient (qDomain alice) $ createWaiTestFedClient @"claim-key-packages" @'Brig $ - ClaimKeyPackageRequest (qUnqualified alice) (qUnqualified bob) + ClaimKeyPackageRequest + (qUnqualified alice) + (qUnqualified bob) + (tagCipherSuite defCipherSuite) liftIO $ mbundle @?= Nothing diff --git a/services/brig/test/integration/API/Internal.hs b/services/brig/test/integration/API/Internal.hs index ad973eff80..3d22a98eb5 100644 --- a/services/brig/test/integration/API/Internal.hs +++ b/services/brig/test/integration/API/Internal.hs @@ -291,25 +291,24 @@ testGetMlsClients :: Brig -> Http () testGetMlsClients brig = do qusr <- userQualifiedId <$> randomUser brig c <- createClient brig qusr 0 - (cs0 :: Set ClientInfo) <- - responseJsonError - =<< get - ( brig - . paths ["i", "mls", "clients", toByteString' (qUnqualified qusr)] - . queryItem "sig_scheme" "ed25519" - ) + + let getClients :: Http (Set ClientInfo) + getClients = + responseJsonError + =<< get + ( brig + . paths ["i", "mls", "clients", toByteString' (qUnqualified qusr)] + . queryItem "ciphersuite" "0x0001" + ) + uploadKeyPackages brig tmp def qusr c 2 - (cs1 :: Set ClientInfo) <- - responseJsonError - =<< get - ( brig - . paths ["i", "mls", "clients", toByteString' (qUnqualified qusr)] - . queryItem "sig_scheme" "ed25519" - ) + cs1 <- getClients liftIO $ toList cs1 @?= [ClientInfo c True] getFeatureConfig :: forall cfg m. (MonadHttp m, HasCallStack, KnownSymbol (ApiFt.FeatureSymbol cfg)) => (Request -> Request) -> UserId -> m ResponseLBS diff --git a/services/brig/test/integration/API/MLS.hs b/services/brig/test/integration/API/MLS.hs index f9220e5590..b578f424d8 100644 --- a/services/brig/test/integration/API/MLS.hs +++ b/services/brig/test/integration/API/MLS.hs @@ -149,8 +149,8 @@ testKeyPackageSelfClaim brig = do =<< post ( brig . paths ["mls", "key-packages", "claim", toByteString' (qDomain u), toByteString' (qUnqualified u)] - . queryItem "skip_own" (toByteString' c1) . zUser (qUnqualified u) + . zClient c1 ) (e.user, e.client)) bundle.entries @?= Set.fromList [(u, c2)] diff --git a/services/galley/default.nix b/services/galley/default.nix index 3acd41013f..727579f2e1 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -233,7 +233,6 @@ mkDerivation { conduit containers cookie - cryptonite currency-codes data-default data-timeout diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index b18ebef1d3..660695de06 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -415,7 +415,6 @@ executable galley-integration , cereal , containers , cookie - , cryptonite , currency-codes , data-default , data-timeout diff --git a/services/galley/src/Galley/API/MLS/Commit/Core.hs b/services/galley/src/Galley/API/MLS/Commit/Core.hs index ce6d32d4c2..12f2a1cf73 100644 --- a/services/galley/src/Galley/API/MLS/Commit/Core.hs +++ b/services/galley/src/Galley/API/MLS/Commit/Core.hs @@ -135,7 +135,7 @@ getClientInfo :: ) => Local x -> Qualified UserId -> - SignatureSchemeTag -> + CipherSuiteTag -> Sem r (Either FederationError (Set ClientInfo)) getClientInfo loc = foldQualified loc (\lusr -> fmap Right . getLocalMLSClients lusr) getRemoteMLSClients @@ -144,14 +144,14 @@ getRemoteMLSClients :: ( Member FederatorAccess r ) => Remote UserId -> - SignatureSchemeTag -> + CipherSuiteTag -> Sem r (Either FederationError (Set ClientInfo)) -getRemoteMLSClients rusr ss = do +getRemoteMLSClients rusr suite = do runFederatedEither rusr $ fedClient @'Brig @"get-mls-clients" $ MLSClientsRequest { userId = tUnqualified rusr, - signatureScheme = ss + cipherSuite = tagCipherSuite suite } -------------------------------------------------------------------------------- diff --git a/services/galley/src/Galley/API/MLS/Commit/InternalCommit.hs b/services/galley/src/Galley/API/MLS/Commit/InternalCommit.hs index ddcd06cefa..b7ac03592c 100644 --- a/services/galley/src/Galley/API/MLS/Commit/InternalCommit.hs +++ b/services/galley/src/Galley/API/MLS/Commit/InternalCommit.hs @@ -53,7 +53,6 @@ import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley -import Wire.API.MLS.CipherSuite import Wire.API.MLS.Commit import Wire.API.MLS.Credential import Wire.API.MLS.Proposal qualified as Proposal @@ -83,7 +82,7 @@ processInternalCommit senderIdentity con lConvOrSub epoch action commit = do let convOrSub = tUnqualified lConvOrSub qusr = cidQualifiedUser senderIdentity cm = convOrSub.members - ss = csSignatureScheme (cnvmlsCipherSuite convOrSub.mlsMeta) + suite = cnvmlsCipherSuite convOrSub.mlsMeta newUserClients = Map.assocs (paAdd action) -- check all pending proposals are referenced in the commit @@ -145,7 +144,7 @@ processInternalCommit senderIdentity con lConvOrSub epoch action commit = do -- final set of clients in the conversation let clients = Map.keysSet (newclients <> Map.findWithDefault mempty qtarget cm) -- get list of mls clients from Brig (local or remote) - getClientInfo lConvOrSub qtarget ss >>= \case + getClientInfo lConvOrSub qtarget suite >>= \case Left _e -> pure (Just qtarget) Right clientInfo -> do let allClients = Set.map ciId clientInfo diff --git a/services/galley/src/Galley/API/MLS/IncomingMessage.hs b/services/galley/src/Galley/API/MLS/IncomingMessage.hs index 96b63cc697..b4a8b7fb20 100644 --- a/services/galley/src/Galley/API/MLS/IncomingMessage.hs +++ b/services/galley/src/Galley/API/MLS/IncomingMessage.hs @@ -69,7 +69,7 @@ data IncomingBundle = IncomingBundle commit :: RawMLS Commit, rawMessage :: RawMLS Message, welcome :: Maybe (RawMLS Welcome), - groupInfo :: GroupInfoData, + groupInfo :: RawMLS GroupInfo, serialized :: ByteString } @@ -126,6 +126,6 @@ mkIncomingBundle bundle = do commit = commit, rawMessage = bundle.value.commitMsg, welcome = bundle.value.welcome, - groupInfo = GroupInfoData bundle.value.groupInfo.raw, + groupInfo = bundle.value.groupInfo, serialized = bundle.raw } diff --git a/services/galley/src/Galley/API/MLS/Message.hs b/services/galley/src/Galley/API/MLS/Message.hs index fedbcaae68..6c52f59c4d 100644 --- a/services/galley/src/Galley/API/MLS/Message.hs +++ b/services/galley/src/Galley/API/MLS/Message.hs @@ -74,6 +74,7 @@ import Wire.API.Error.Galley import Wire.API.Federation.API import Wire.API.Federation.API.Galley import Wire.API.Federation.Error +import Wire.API.MLS.CipherSuite import Wire.API.MLS.Commit hiding (output) import Wire.API.MLS.CommitBundle import Wire.API.MLS.Credential @@ -204,7 +205,27 @@ postMLSCommitBundleToLocalConv :: Local ConvOrSubConvId -> Sem r [LocalConversationUpdate] postMLSCommitBundleToLocalConv qusr c conn bundle ctype lConvOrSubId = do - lConvOrSub <- fetchConvOrSub qusr bundle.groupId ctype lConvOrSubId + lConvOrSub <- do + lConvOrSub <- fetchConvOrSub qusr bundle.groupId ctype lConvOrSubId + let convOrSub = tUnqualified lConvOrSub + giCipherSuite <- + note (mlsProtocolError "Unsupported ciphersuite") $ + cipherSuiteTag bundle.groupInfo.value.groupContext.cipherSuite + let convCipherSuite = convOrSub.mlsMeta.cnvmlsCipherSuite + -- if this is the first commit of the conversation, update ciphersuite + if (giCipherSuite == convCipherSuite) + then pure lConvOrSub + else do + unless (convOrSub.mlsMeta.cnvmlsEpoch == Epoch 0) $ + throw $ + mlsProtocolError "GroupInfo ciphersuite does not match conversation" + -- save to cassandra + case convOrSub.id of + Conv cid -> setConversationCipherSuite cid giCipherSuite + SubConv cid sub -> + setSubConversationCipherSuite cid sub giCipherSuite + pure $ fmap (convOrSubConvSetCipherSuite giCipherSuite) lConvOrSub + senderIdentity <- getSenderIdentity qusr c bundle.sender lConvOrSub (events, newClients) <- case bundle.sender of @@ -236,7 +257,7 @@ postMLSCommitBundleToLocalConv qusr c conn bundle ctype lConvOrSubId = do bundle.commit.value.path pure ([], []) - storeGroupInfo (tUnqualified lConvOrSub).id bundle.groupInfo + storeGroupInfo (tUnqualified lConvOrSub).id (GroupInfoData bundle.groupInfo.raw) propagateMessage qusr (Just c) lConvOrSub conn bundle.rawMessage (tUnqualified lConvOrSub).members >>= mapM_ (throw . unreachableUsersToUnreachableBackends) diff --git a/services/galley/src/Galley/API/MLS/Proposal.hs b/services/galley/src/Galley/API/MLS/Proposal.hs index fb4e9273af..0005765d19 100644 --- a/services/galley/src/Galley/API/MLS/Proposal.hs +++ b/services/galley/src/Galley/API/MLS/Proposal.hs @@ -163,20 +163,19 @@ checkProposal :: IndexMap -> Proposal -> Sem r () -checkProposal mlsMeta im p = - case p of - AddProposal kp -> do - (cs, _lifetime) <- - either - (\msg -> throw (mlsProtocolError ("Invalid key package in Add proposal: " <> msg))) - pure - $ validateKeyPackage Nothing kp.value - -- we are not checking lifetime constraints here - unless (mlsMeta.cnvmlsCipherSuite == cs) $ - throw (mlsProtocolError "Key package ciphersuite does not match conversation") - RemoveProposal idx -> do - void $ noteS @'MLSInvalidLeafNodeIndex $ imLookup im idx - _ -> pure () +checkProposal mlsMeta im p = case p of + AddProposal kp -> do + (cs, _lifetime) <- + either + (\msg -> throw (mlsProtocolError ("Invalid key package in Add proposal: " <> msg))) + pure + $ validateKeyPackage Nothing kp.value + -- we are not checking lifetime constraints here + unless (mlsMeta.cnvmlsCipherSuite == cs) $ + throw (mlsProtocolError "Key package ciphersuite does not match conversation") + RemoveProposal idx -> do + void $ noteS @'MLSInvalidLeafNodeIndex $ imLookup im idx + _ -> pure () addProposedClient :: Member (State IndexMap) r => ClientIdentity -> Sem r ProposalAction addProposedClient cid = do @@ -248,6 +247,10 @@ processProposal qusr lConvOrSub groupId epoch pub prop = do unless (groupId == cnvmlsGroupId mlsMeta) $ throwS @'ConvNotFound let suiteTag = cnvmlsCipherSuite mlsMeta + -- Reject proposals before first commit + when (mlsMeta.cnvmlsEpoch == Epoch 0) $ + throw (mlsProtocolError "Bare proposals at epoch 0 are not supported") + -- FUTUREWORK: validate the member's conversation role checkProposal mlsMeta (tUnqualified lConvOrSub).indexMap prop.value when (isExternal pub.sender) $ checkExternalProposalUser qusr prop.value diff --git a/services/galley/src/Galley/API/MLS/Types.hs b/services/galley/src/Galley/API/MLS/Types.hs index 58f97bc24c..13a14d9b6a 100644 --- a/services/galley/src/Galley/API/MLS/Types.hs +++ b/services/galley/src/Galley/API/MLS/Types.hs @@ -27,7 +27,7 @@ import Data.Qualified import GHC.Records (HasField (..)) import Galley.Data.Conversation.Types import Galley.Types.Conversations.Members -import Imports +import Imports hiding (cs) import Wire.API.Conversation import Wire.API.Conversation.Protocol import Wire.API.MLS.CipherSuite @@ -214,3 +214,15 @@ instance HasField "id" ConvOrSubConv ConvOrSubConvId where instance HasField "migrationState" ConvOrSubConv MLSMigrationState where getField (Conv c) = c.mcMigrationState getField (SubConv _ _) = MLSMigrationMLS + +convOrSubConvSetCipherSuite :: CipherSuiteTag -> ConvOrSubConv -> ConvOrSubConv +convOrSubConvSetCipherSuite cs (Conv c) = + Conv $ + c + { mcMLSData = (mcMLSData c) {cnvmlsCipherSuite = cs} + } +convOrSubConvSetCipherSuite cs (SubConv c s) = + SubConv c $ + s + { scMLSData = (scMLSData s) {cnvmlsCipherSuite = cs} + } diff --git a/services/galley/src/Galley/Cassandra/Conversation.hs b/services/galley/src/Galley/Cassandra/Conversation.hs index 29eba7f852..2d24adb63b 100644 --- a/services/galley/src/Galley/Cassandra/Conversation.hs +++ b/services/galley/src/Galley/Cassandra/Conversation.hs @@ -244,6 +244,13 @@ getConvEpoch cid = updateConvEpoch :: ConvId -> Epoch -> Client () updateConvEpoch cid epoch = retry x5 $ write Cql.updateConvEpoch (params LocalQuorum (epoch, cid)) +updateConvCipherSuite :: ConvId -> CipherSuiteTag -> Client () +updateConvCipherSuite cid cs = + retry x5 $ + write + Cql.updateConvCipherSuite + (params LocalQuorum (cs, cid)) + setGroupInfo :: ConvId -> GroupInfoData -> Client () setGroupInfo conv gid = write Cql.updateGroupInfo (params LocalQuorum (gid, conv)) @@ -460,6 +467,7 @@ interpretConversationStoreToCassandra = interpret $ \case SetConversationReceiptMode cid value -> embedClient $ updateConvReceiptMode cid value SetConversationMessageTimer cid value -> embedClient $ updateConvMessageTimer cid value SetConversationEpoch cid epoch -> embedClient $ updateConvEpoch cid epoch + SetConversationCipherSuite cid cs -> embedClient $ updateConvCipherSuite cid cs DeleteConversation cid -> embedClient $ deleteConversation cid SetGroupInfo cid gib -> embedClient $ setGroupInfo cid gib AcquireCommitLock gId epoch ttl -> embedClient $ acquireCommitLock gId epoch ttl diff --git a/services/galley/src/Galley/Cassandra/Queries.hs b/services/galley/src/Galley/Cassandra/Queries.hs index 3ef718aa8e..25bd766c78 100644 --- a/services/galley/src/Galley/Cassandra/Queries.hs +++ b/services/galley/src/Galley/Cassandra/Queries.hs @@ -296,6 +296,9 @@ getConvEpoch = "select epoch from conversation where conv = ?" updateConvEpoch :: PrepQuery W (Epoch, ConvId) () updateConvEpoch = {- `IF EXISTS`, but that requires benchmarking -} "update conversation set epoch = ? where conv = ?" +updateConvCipherSuite :: PrepQuery W (CipherSuiteTag, ConvId) () +updateConvCipherSuite = "update conversation set cipher_suite = ? where conv = ?" + deleteConv :: PrepQuery W (Identity ConvId) () deleteConv = "delete from conversation using timestamp 32503680000000000 where conv = ?" @@ -338,7 +341,7 @@ deleteUserConv = "delete from user where user = ? and conv = ?" -- MLS SubConversations ----------------------------------------------------- -selectSubConversation :: PrepQuery R (ConvId, SubConvId) (CipherSuiteTag, Epoch, Writetime Epoch, GroupId) +selectSubConversation :: PrepQuery R (ConvId, SubConvId) (Maybe CipherSuiteTag, Maybe Epoch, Maybe (Writetime Epoch), Maybe GroupId) selectSubConversation = "SELECT cipher_suite, epoch, WRITETIME(epoch), group_id FROM subconversation WHERE conv_id = ? and subconv_id = ?" insertSubConversation :: PrepQuery W (ConvId, SubConvId, CipherSuiteTag, Epoch, GroupId, Maybe GroupInfoData) () @@ -356,6 +359,9 @@ selectSubConvEpoch = "SELECT epoch FROM subconversation WHERE conv_id = ? AND su insertEpochForSubConversation :: PrepQuery W (Epoch, ConvId, SubConvId) () insertEpochForSubConversation = "UPDATE subconversation set epoch = ? WHERE conv_id = ? AND subconv_id = ?" +insertCipherSuiteForSubConversation :: PrepQuery W (CipherSuiteTag, ConvId, SubConvId) () +insertCipherSuiteForSubConversation = "UPDATE subconversation set cipher_suite = ? WHERE conv_id = ? AND subconv_id = ?" + listSubConversations :: PrepQuery R (Identity ConvId) (SubConvId, CipherSuiteTag, Epoch, Writetime Epoch, GroupId) listSubConversations = "SELECT subconv_id, cipher_suite, epoch, WRITETIME(epoch), group_id FROM subconversation WHERE conv_id = ?" diff --git a/services/galley/src/Galley/Cassandra/SubConversation.hs b/services/galley/src/Galley/Cassandra/SubConversation.hs index 8a9a0287c1..5827435aaa 100644 --- a/services/galley/src/Galley/Cassandra/SubConversation.hs +++ b/services/galley/src/Galley/Cassandra/SubConversation.hs @@ -22,6 +22,8 @@ where import Cassandra import Cassandra.Util +import Control.Error.Util +import Control.Monad.Trans.Maybe import Data.Id import Data.Map qualified as Map import Data.Time.Clock @@ -40,24 +42,29 @@ import Wire.API.MLS.GroupInfo import Wire.API.MLS.SubConversation selectSubConversation :: ConvId -> SubConvId -> Client (Maybe SubConversation) -selectSubConversation convId subConvId = do - m <- retry x5 (query1 Cql.selectSubConversation (params LocalQuorum (convId, subConvId))) - for m $ \(suite, epoch, epochWritetime, groupId) -> do - (cm, im) <- lookupMLSClientLeafIndices groupId - pure $ - SubConversation - { scParentConvId = convId, - scSubConvId = subConvId, - scMLSData = - ConversationMLSData - { cnvmlsGroupId = groupId, - cnvmlsEpoch = epoch, - cnvmlsEpochTimestamp = epochTimestamp epoch epochWritetime, - cnvmlsCipherSuite = suite - }, - scMembers = cm, - scIndexMap = im - } +selectSubConversation convId subConvId = runMaybeT $ do + (mSuite, mEpoch, mEpochWritetime, mGroupId) <- + MaybeT $ + retry x5 (query1 Cql.selectSubConversation (params LocalQuorum (convId, subConvId))) + suite <- hoistMaybe mSuite + epoch <- hoistMaybe mEpoch + epochWritetime <- hoistMaybe mEpochWritetime + groupId <- hoistMaybe mGroupId + (cm, im) <- lift $ lookupMLSClientLeafIndices groupId + pure $ + SubConversation + { scParentConvId = convId, + scSubConvId = subConvId, + scMLSData = + ConversationMLSData + { cnvmlsGroupId = groupId, + cnvmlsEpoch = epoch, + cnvmlsEpochTimestamp = epochTimestamp epoch epochWritetime, + cnvmlsCipherSuite = suite + }, + scMembers = cm, + scIndexMap = im + } insertSubConversation :: ConvId -> @@ -93,6 +100,10 @@ setEpochForSubConversation :: ConvId -> SubConvId -> Epoch -> Client () setEpochForSubConversation cid sconv epoch = retry x5 (write Cql.insertEpochForSubConversation (params LocalQuorum (epoch, cid, sconv))) +setCipherSuiteForSubConversation :: ConvId -> SubConvId -> CipherSuiteTag -> Client () +setCipherSuiteForSubConversation cid sconv cs = + retry x5 (write Cql.insertCipherSuiteForSubConversation (params LocalQuorum (cs, cid, sconv))) + deleteSubConversation :: ConvId -> SubConvId -> Client () deleteSubConversation cid sconv = retry x5 $ write Cql.deleteSubConversation (params LocalQuorum (cid, sconv)) @@ -124,6 +135,7 @@ interpretSubConversationStoreToCassandra = interpret $ \case GetSubConversationEpoch convId subConvId -> embedClient (selectSubConvEpoch convId subConvId) SetSubConversationGroupInfo convId subConvId mPgs -> embedClient (updateSubConvGroupInfo convId subConvId mPgs) SetSubConversationEpoch cid sconv epoch -> embedClient $ setEpochForSubConversation cid sconv epoch + SetSubConversationCipherSuite cid sconv cs -> embedClient $ setCipherSuiteForSubConversation cid sconv cs ListSubConversations cid -> embedClient $ listSubConversations cid DeleteSubConversation convId subConvId -> embedClient $ deleteSubConversation convId subConvId diff --git a/services/galley/src/Galley/Effects/BrigAccess.hs b/services/galley/src/Galley/Effects/BrigAccess.hs index 68c4d21bd7..642a3ab4c1 100644 --- a/services/galley/src/Galley/Effects/BrigAccess.hs +++ b/services/galley/src/Galley/Effects/BrigAccess.hs @@ -123,7 +123,7 @@ data BrigAccess m a where BrigAccess m (Either AuthenticationError ClientId) RemoveLegalHoldClientFromUser :: UserId -> BrigAccess m () GetAccountConferenceCallingConfigClient :: UserId -> BrigAccess m (WithStatusNoLock ConferenceCallingConfig) - GetLocalMLSClients :: Local UserId -> SignatureSchemeTag -> BrigAccess m (Set ClientInfo) + GetLocalMLSClients :: Local UserId -> CipherSuiteTag -> BrigAccess m (Set ClientInfo) UpdateSearchVisibilityInbound :: Multi.TeamStatus SearchVisibilityInboundConfig -> BrigAccess m () diff --git a/services/galley/src/Galley/Effects/ConversationStore.hs b/services/galley/src/Galley/Effects/ConversationStore.hs index 3bdf680881..cd0a2e8dce 100644 --- a/services/galley/src/Galley/Effects/ConversationStore.hs +++ b/services/galley/src/Galley/Effects/ConversationStore.hs @@ -43,6 +43,7 @@ module Galley.Effects.ConversationStore setConversationReceiptMode, setConversationMessageTimer, setConversationEpoch, + setConversationCipherSuite, acceptConnectConversation, setGroupInfo, updateToMixedProtocol, @@ -96,6 +97,7 @@ data ConversationStore m a where SetConversationReceiptMode :: ConvId -> ReceiptMode -> ConversationStore m () SetConversationMessageTimer :: ConvId -> Maybe Milliseconds -> ConversationStore m () SetConversationEpoch :: ConvId -> Epoch -> ConversationStore m () + SetConversationCipherSuite :: ConvId -> CipherSuiteTag -> ConversationStore m () SetGroupInfo :: ConvId -> GroupInfoData -> ConversationStore m () AcquireCommitLock :: GroupId -> Epoch -> NominalDiffTime -> ConversationStore m LockAcquired ReleaseCommitLock :: GroupId -> Epoch -> ConversationStore m () diff --git a/services/galley/src/Galley/Effects/SubConversationStore.hs b/services/galley/src/Galley/Effects/SubConversationStore.hs index b70b1167e8..2179781b13 100644 --- a/services/galley/src/Galley/Effects/SubConversationStore.hs +++ b/services/galley/src/Galley/Effects/SubConversationStore.hs @@ -36,6 +36,7 @@ data SubConversationStore m a where GetSubConversationEpoch :: ConvId -> SubConvId -> SubConversationStore m (Maybe Epoch) SetSubConversationGroupInfo :: ConvId -> SubConvId -> Maybe GroupInfoData -> SubConversationStore m () SetSubConversationEpoch :: ConvId -> SubConvId -> Epoch -> SubConversationStore m () + SetSubConversationCipherSuite :: ConvId -> SubConvId -> CipherSuiteTag -> SubConversationStore m () ListSubConversations :: ConvId -> SubConversationStore m (Map SubConvId ConversationMLSData) DeleteSubConversation :: ConvId -> SubConvId -> SubConversationStore m () diff --git a/services/galley/src/Galley/Intra/Client.hs b/services/galley/src/Galley/Intra/Client.hs index f8890c9bc1..5907498ad6 100644 --- a/services/galley/src/Galley/Intra/Client.hs +++ b/services/galley/src/Galley/Intra/Client.hs @@ -30,7 +30,7 @@ import Bilge hiding (getHeader, options, statusCode) import Bilge.RPC import Brig.Types.Intra import Brig.Types.Team.LegalHold (LegalHoldClientRequest (..)) -import Data.ByteString.Conversion (toByteString') +import Data.ByteString.Conversion import Data.Id import Data.Misc import Data.Qualified @@ -50,6 +50,7 @@ import Polysemy import Polysemy.Error import Polysemy.Input import Polysemy.TinyLog qualified as P +import Servant.API import System.Logger.Class qualified as Logger import Wire.API.Error.Galley import Wire.API.MLS.CipherSuite @@ -171,8 +172,8 @@ brigAddClient uid connId client = do else pure (Left ReAuthFailed) -- | Calls 'Brig.API.Internal.getMLSClients'. -getLocalMLSClients :: Local UserId -> SignatureSchemeTag -> App (Set ClientInfo) -getLocalMLSClients lusr ss = +getLocalMLSClients :: Local UserId -> CipherSuiteTag -> App (Set ClientInfo) +getLocalMLSClients lusr suite = call Brig ( method GET @@ -182,7 +183,9 @@ getLocalMLSClients lusr ss = "clients", toByteString' (tUnqualified lusr) ] - . queryItem "sig_scheme" (toByteString' (signatureSchemeName ss)) + . queryItem + "ciphersuite" + (toHeader (tagCipherSuite suite)) . expect2xx ) >>= parseResponse (mkError status502 "server-error") diff --git a/services/galley/test/integration/API/MLS.hs b/services/galley/test/integration/API/MLS.hs index 5318a9f549..79e6918c63 100644 --- a/services/galley/test/integration/API/MLS.hs +++ b/services/galley/test/integration/API/MLS.hs @@ -28,8 +28,6 @@ import Cassandra hiding (Set) import Control.Lens (view) import Control.Lens.Extras import Control.Monad.State qualified as State -import Crypto.Error -import Crypto.PubKey.Ed25519 qualified as Ed25519 import Data.Aeson qualified as Aeson import Data.Domain import Data.Id @@ -58,12 +56,9 @@ import Wire.API.Conversation.Role import Wire.API.Error.Galley import Wire.API.Event.Conversation import Wire.API.Federation.API.Galley -import Wire.API.MLS.AuthenticatedContent import Wire.API.MLS.CipherSuite import Wire.API.MLS.Credential import Wire.API.MLS.Keys -import Wire.API.MLS.Message -import Wire.API.MLS.Proposal import Wire.API.MLS.Serialisation import Wire.API.MLS.SubConversation import Wire.API.Message @@ -98,13 +93,10 @@ tests s = [ test s "add user (not connected)" testAddUserNotConnected, test s "add client of existing user" testAddClientPartial, test s "add user with some non-MLS clients" testAddUserWithProteusClients, - test s "send a stale commit" testStaleCommit, test s "add remote user to a conversation" testAddRemoteUser, test s "add remote users to a conversation (some unreachable)" testAddRemotesSomeUnreachable, test s "return error when commit is locked" testCommitLock, - test s "add user to a conversation with proposal + commit" testAddUserBareProposalCommit, - test s "post commit that references an unknown proposal" testUnknownProposalRefCommit, - test s "post commit that is not referencing all proposals" testCommitNotReferencingAllProposals + test s "post commit that references an unknown proposal" testUnknownProposalRefCommit ], testGroup "External commit" @@ -141,10 +133,7 @@ tests s = ], testGroup "Proposal" - [ test s "add a new client to a non-existing conversation" propNonExistingConv, - test s "add a new client to an existing conversation" propExistingConv, - test s "add a new client in an invalid epoch" propInvalidEpoch, - test s "forward an unsupported proposal" propUnsupported + [ test s "add a new client to a non-existing conversation" propNonExistingConv ], testGroup "External Add Proposal" @@ -493,32 +482,6 @@ testProteusMessage = do >= sendAndConsumeCommitBundle - - -- now roll back alice1 and try to add the second batch of users - setClientGroupState alice1 gsBackup - - commit <- createAddCommit alice1 users2 - bundle <- createBundle commit - err <- - responseJsonError - =<< localPostCommitBundle (mpSender commit) bundle - >= traverse_ sendAndConsumeMessage - commit <- createPendingProposalCommit alice1 - void $ assertJust (mpWelcome commit) - void $ sendAndConsumeCommitBundle commit - - -- check that bob can now see the conversation - liftTest $ do - convs <- getAllConvs (ciUser bob1) - liftIO $ - assertBool - "Users added to an MLS group should find it when listing conversations" - (qcnv `elem` map cnvQualifiedId convs) - testUnknownProposalRefCommit :: TestM () testUnknownProposalRefCommit = do [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) @@ -665,33 +606,6 @@ testUnknownProposalRefCommit = do >= traverse_ sendAndConsumeMessage - - -- now create a commit referencing only the first proposal - setClientGroupState alice1 gsBackup - commit <- createPendingProposalCommit alice1 - - -- send commit and expect and error - bundle <- createBundle commit - err <- - responseJsonError - =<< localPostCommitBundle alice1 bundle - res) @?= [[]] - -propInvalidEpoch :: TestM () -propInvalidEpoch = do - users@[_alice, bob, charlie, dee] <- createAndConnectUsers (replicate 4 Nothing) - runMLSTest $ do - [alice1, bob1, charlie1, dee1] <- traverse createMLSClient users - void $ setupMLSGroup alice1 - - -- Add bob -> epoch 1 - void $ uploadNewKeyPackage bob1 - gsBackup <- getClientGroupState alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle - gsBackup2 <- getClientGroupState alice1 - - -- try to send a proposal from an old epoch (0) - do - setClientGroupState alice1 gsBackup - void $ uploadNewKeyPackage dee1 - [prop] <- createAddProposals alice1 [dee] - err <- - responseJsonError - =<< postMessage alice1 (mpMessage prop) - epoch 2 - [prop] <- createAddProposals alice1 [dee] - err <- - responseJsonError - =<< postMessage alice1 (mpMessage prop) - mls {mlsNewMembers = mempty} - - -- alice send a well-formed proposal and commits it - void $ uploadNewKeyPackage dee1 - setClientGroupState alice1 gsBackup2 - createAddProposals alice1 [dee] >>= traverse_ sendAndConsumeMessage - void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle - -- scenario: -- alice1 creates a group and adds bob1 -- bob2 joins with external proposal (alice1 commits it) @@ -1531,38 +1389,6 @@ testPublicKeys = do ) @?= [Ed25519] ---- | The test manually reads from mls-test-cli's store and extracts a private ---- key. The key is needed for signing an unsupported proposal, which is then --- forwarded by the backend without being inspected. -propUnsupported :: TestM () -propUnsupported = do - users@[_alice, bob] <- createAndConnectUsers (replicate 2 Nothing) - runMLSTest $ do - [alice1, bob1] <- traverse createMLSClient users - void $ uploadNewKeyPackage bob1 - (gid, _) <- setupMLSGroup alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle - - (priv, pub) <- clientKeyPair alice1 - pmsg <- - liftIO . throwCryptoErrorIO $ - mkSignedPublicMessage - <$> Ed25519.secretKey priv - <*> Ed25519.publicKey pub - <*> pure gid - <*> pure (Epoch 1) - <*> pure (TaggedSenderMember 0 "foo") - <*> pure - ( FramedContentProposal - (mkRawMLS (GroupContextExtensionsProposal [])) - ) - - let msg = mkMessage (MessagePublic pmsg) - let msgData = encodeMLS' msg - - -- we cannot consume this message, because the membership tag is fake - postMessage alice1 msgData !!! const 201 === statusCode - testBackendRemoveProposalRecreateClient :: TestM () testBackendRemoveProposalRecreateClient = do alice <- randomQualifiedUser diff --git a/services/galley/test/integration/API/MLS/Util.hs b/services/galley/test/integration/API/MLS/Util.hs index ebd2d6d49f..aca41bd50d 100644 --- a/services/galley/test/integration/API/MLS/Util.hs +++ b/services/galley/test/integration/API/MLS/Util.hs @@ -661,6 +661,7 @@ createApplicationMessage cid messageContent = do } createAddCommitWithKeyPackages :: + HasCallStack => ClientIdentity -> [(ClientIdentity, ByteString)] -> MLSTest MessagePackage