-- | Test our interoperability with Python Magic Wormhole. -- -- Some notes: -- * it is the developer's / CI server's responsibility to provide a Python installation and a magic-wormhole installation -- * we currently make no attempt to handle different versions of magic-wormhole -- * if Python is not present, these tests will pass -- * if magic-wormhole is not present, these tests will pass module Integration (tests) where import Protolude hiding (phase, stdin, stdout) import Control.Concurrent.STM.TChan ( newTChan , readTChan , writeTChan ) import qualified Crypto.Saltine.Class as Saltine import qualified Crypto.Saltine.Core.SecretBox as SecretBox import qualified Data.Aeson as Aeson import Data.ByteArray.Encoding (convertFromBase, convertToBase, Base(Base16)) import qualified Data.ByteString as ByteString import qualified Data.ByteString.Char8 as Char8 import Data.String (String) import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range import qualified System.IO as IO import qualified System.Process as Process import Test.Tasty (TestTree) import Test.Tasty.Hspec (testSpec, describe, it, shouldBe) import qualified Crypto.Spake2 as Spake2 import qualified MagicWormhole.Internal.ClientProtocol as ClientProtocol import qualified MagicWormhole.Internal.Messages as Messages import qualified MagicWormhole.Internal.Pake as Pake import qualified MagicWormhole.Internal.Versions as Versions import qualified Paths_magic_wormhole tests :: IO TestTree tests = testSpec "Integration" $ do describe "SPAKE2 and version exchange" $ do it "Generates the same SPAKE2 session key as the python-spake2 library" $ do let appID = "jml.io/haskell-magic-wormhole-test" let password = "mellon" let password' = Spake2.makePassword password let ourSide = "treebeard" let theirSide = "saruman" interactWithPython "tests/python/spake2_exchange.py" [ "--app-id=" <> toS appID , "--code=" <> toS password , "--side=" <> theirSide ] $ \stdin stdout -> do ClientProtocol.SessionKey sessionKey <- withConnection (Messages.AppID appID) (Messages.Side ourSide) stdin stdout $ \conn -> Pake.pakeExchange conn password' -- Calculate the shared key theirSpakeKey <- ByteString.hGetLine stdout theirSpakeKey `shouldBe` convertToBase Base16 sessionKey it "Derives the same phase keys" $ do fakeSpakeKey <- Gen.sample $ Gen.bytes (Range.singleton 32) let side = "treebeard" let phase = Messages.VersionPhase let ourPhaseKey = ClientProtocol.deriveKey (ClientProtocol.SessionKey fakeSpakeKey) (ClientProtocol.phasePurpose (Messages.Side side) phase) interactWithPython "tests/python/derive_phase_key.py" [ "--spake-key=" <> toS (convertToBase Base16 fakeSpakeKey :: ByteString) , "--side=" <> toS side , "--phase=" <> toS (Messages.phaseName phase) ] $ \_stdin stdout -> do theirPhaseKey <- ByteString.hGetLine stdout theirPhaseKey `shouldBe` convertToBase Base16 (Saltine.encode ourPhaseKey) it "Works with our hacked-together Python implementation" $ do let appID = "jml.io/haskell-magic-wormhole-test" let ourSide = "treebeard" :: Text let otherSide = "saruman" :: Text let password = "mellon" let password' = Spake2.makePassword password interactWithPython "tests/python/version_exchange.py" [ "--app-id=" <> toS appID , "--side=" <> toS otherSide , "--code=" <> toS password ] $ \stdin stdout -> do versions <- withConnection (Messages.AppID appID) (Messages.Side ourSide) stdin stdout $ \conn -> do sessionKey <- Pake.pakeExchange conn password' Versions.versionExchange conn sessionKey versions `shouldBe` Versions.Versions describe "NaCL interoperability" $ it "Swaps messages with Python" $ do key <- SecretBox.newKey nonce <- SecretBox.newNonce interactWithPython "tests/python/nacl_exchange.py" [ "--key=" <> toS (convertToBase Base16 (Saltine.encode key) :: ByteString) , "--nonce=" <> toS (convertToBase Base16 (Saltine.encode nonce) :: ByteString) ] $ \stdin stdout -> do let message = ClientProtocol.PlainText "Hello world!" ClientProtocol.CipherText encryptedByUs <- ClientProtocol.encrypt key message Char8.hPutStrLn stdin (convertToBase Base16 encryptedByUs :: ByteString) decryptedByPython <- ByteString.hGetLine stdout ClientProtocol.PlainText decryptedByPython `shouldBe` message encryptedByPython <- ByteString.hGetLine stdout let Right encryptedBytes = convertFromBase Base16 encryptedByPython let Right decryptedByUs = ClientProtocol.decrypt key (ClientProtocol.CipherText encryptedBytes) decryptedByUs `shouldBe` message -- | Run a Python script and interact with it by sending stuff to its stdin -- and reading from its stdout using a line-based protocol. -- -- The Python process's stderr will inherit from this one, so we get Python -- stack traces in our test runner output. The interaction won't terminate -- until the Python process does, so that we can get full output from it, -- especially in the case of errors. interactWithPython :: FilePath -- ^ Name of the script to run -> [String] -- ^ Arguments to the script -> (Handle -> Handle -> IO a) -- ^ Interaction with the script, params are stdin & stdout. -> IO a -- ^ Result of the interaction. interactWithPython name args action = do scriptExe <- Paths_magic_wormhole.getDataFileName name let testScript = (Process.proc "python" (scriptExe:args)) { Process.std_in = Process.CreatePipe , Process.std_out = Process.CreatePipe , Process.std_err = Process.Inherit } Process.withCreateProcess testScript $ \(Just stdin) (Just stdout) _stderr ph -> do IO.hSetBuffering stdin IO.LineBuffering IO.hSetBuffering stdout IO.LineBuffering IO.hSetBuffering stderr IO.LineBuffering action stdin stdout `finally` Process.waitForProcess ph withConnection :: Messages.AppID -> Messages.Side -> Handle -> Handle -> (ClientProtocol.Connection -> IO a) -> IO a withConnection appID ourSide stdin stdout action = do inChan <- atomically newTChan let connection = ClientProtocol.Connection { ClientProtocol.appID = appID , ClientProtocol.ourSide = ourSide , ClientProtocol.send = send , ClientProtocol.receive = readTChan inChan } result <- race (receiveForever inChan) (action connection) case result of Left err -> panic ("Couldn't read messages: " <> show @Text err) Right r -> pure r where send phase body = sendMailboxMessage stdin Messages.MailboxMessage { Messages.phase = phase , Messages.side = ourSide , Messages.body = body , Messages.messageID = Nothing } receiveForever inChan = forever $ do msg <- readMailboxMessage stdout atomically $ writeTChan inChan msg readMailboxMessage :: HasCallStack => Handle -> IO Messages.MailboxMessage readMailboxMessage h = do line <- ByteString.hGetLine h case Aeson.eitherDecode (toS line) of Left err -> do hPutStrLn stderr $ "Could not decode line: " <> line panic (toS err) Right (Messages.Message result) -> pure result Right other -> do hPutStrLn @Text stderr $ "Decoded line to non-MailboxMessage: " <> show other panic (show other) sendMailboxMessage :: HasCallStack => Handle -> Messages.MailboxMessage -> IO () sendMailboxMessage h msg = hPutStrLn h (Aeson.encode (Messages.Message msg))