packages feed

warp-s2n-tls-0.1.0.0: test/ProtocolTests.hs

{-# LANGUAGE NumericUnderscores #-}

module ProtocolTests (protocolSpec) where

import Control.Applicative
import Control.Exception (SomeException, bracket, try)
import Data.Default.Class (def)
import Network.HTTP.Types (status200)
import Network.Socket qualified as Socket
import Network.Socket.ByteString qualified as SocketBS
import Network.TLS qualified as TLS
import Network.TLS.Extra.Cipher qualified as TLS
import Network.Wai (responseLBS)
import Network.Wai.Handler.Warp (defaultSettings, setBeforeMainLoop)
import Network.Wai.Handler.WarpS2N (
    TLSSettings (..),
    runTLSSocketLib,
    tlsSettings,
 )
import S2nTls (S2nTls (..))
import Test.Hspec (SpecWith, describe, expectationFailure, it, shouldBe)
import TestUtils
import UnliftIO.Async
import UnliftIO.Concurrent
import UnliftIO.STM

protocolSpec :: SpecWith S2nTls
protocolSpec = do
    describe "TLS version negotiation" versionNegotiationSpec
    describe "Cipher preferences" cipherPreferencesSpec

versionNegotiationSpec :: SpecWith S2nTls
versionNegotiationSpec = do
    it "negotiates TLS 1.3 with default settings" $ \tls -> do
        let serverSettings = tlsSettings testCertPath testKeyPath
        negotiatedVersion <- withTestServerGetVersion tls serverSettings [TLS.TLS13, TLS.TLS12]
        case negotiatedVersion of
            Just TLS.TLS13 -> pure ()
            Just v -> expectationFailure $ "Expected TLS 1.3, got " ++ show v
            Nothing -> expectationFailure "Failed to get negotiated version"

    it "negotiates TLS 1.2 when client only supports TLS 1.2" $ \tls -> do
        let serverSettings =
                (tlsSettings testCertPath testKeyPath)
                    { tlsCipherPreferences = "default"
                    }
        negotiatedVersion <- withTestServerGetVersion tls serverSettings [TLS.TLS12]
        case negotiatedVersion of
            Just TLS.TLS12 -> pure ()
            Just v -> expectationFailure $ "Expected TLS 1.2, got " ++ show v
            Nothing -> expectationFailure "Failed to get negotiated version"

    it "fails when no common TLS version" $ \tls -> do
        let serverSettings =
                (tlsSettings testCertPath testKeyPath)
                    { tlsCipherPreferences = "default_tls13"
                    }
        result <-
            try @SomeException $
                withTestServerGetVersion tls serverSettings [TLS.TLS10]
        case result of
            Left _ -> pure ()
            Right (Just v) -> expectationFailure $ "Should have failed but got " ++ show v
            Right Nothing -> pure ()

cipherPreferencesSpec :: SpecWith S2nTls
cipherPreferencesSpec = do
    it "accepts connection with default_tls13 policy" $ \tls -> do
        let serverSettings =
                (tlsSettings testCertPath testKeyPath)
                    { tlsCipherPreferences = "default_tls13"
                    }
        result <- withTestServerConnect tls serverSettings
        result `shouldBe` True

    it "accepts connection with default policy" $ \tls -> do
        let serverSettings =
                (tlsSettings testCertPath testKeyPath)
                    { tlsCipherPreferences = "default"
                    }
        result <- withTestServerConnect tls serverSettings
        result `shouldBe` True

-- | Helper: Start server and get negotiated TLS version from client perspective
withTestServerGetVersion :: S2nTls -> TLSSettings -> [TLS.Version] -> IO (Maybe TLS.Version)
withTestServerGetVersion tls tlsSet clientVersions =
    bracket bindFreePort (Socket.close . fst) $ \(sock, port) -> do
        serverReady <- newEmptyTMVarIO
        let app _ respond = respond $ responseLBS status200 [] "blarg!"
            warpSet = setBeforeMainLoop (atomically $ putTMVar serverReady ()) defaultSettings
        withAsync (runTLSSocketLib tls tlsSet warpSet sock app) $ \as -> do
            atomically $ waitSTM as <|> takeTMVar serverReady
            threadDelay 10_000
            backend <- makeClientSocket port
            params <- makeClientParams clientVersions
            ctx <- TLS.contextNew backend params
            TLS.handshake ctx
            info <- TLS.contextGetInformation ctx
            let version = TLS.infoVersion <$> info
            TLS.bye ctx
            pure version

-- | Helper: Start server and test if connection succeeds
withTestServerConnect :: S2nTls -> TLSSettings -> IO Bool
withTestServerConnect tls tlsSet =
    bracket bindFreePort (Socket.close . fst) $ \(sock, port) -> do
        serverReady <- newEmptyTMVarIO

        let app _ respond = respond $ responseLBS status200 [] "blarg!"
            warpSet = setBeforeMainLoop (atomically $ putTMVar serverReady ()) defaultSettings

        withAsync (runTLSSocketLib tls tlsSet warpSet sock app) $ \as -> do
            atomically $ waitSTM as <|> takeTMVar serverReady
            threadDelay 10_000

            result <- try @SomeException $ do
                backend <- makeClientSocket port
                params <- makeClientParams [TLS.TLS13, TLS.TLS12]
                ctx <- TLS.contextNew backend params
                TLS.handshake ctx
                TLS.sendData ctx "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
                _ <- TLS.recvData ctx
                TLS.bye ctx

            pure $ case result of
                Left _ -> False
                Right _ -> True

-- | Create client TLS parameters
makeClientParams :: [TLS.Version] -> IO TLS.ClientParams
makeClientParams versions = do
    pure $
        (TLS.defaultParamsClient "localhost" "")
            { TLS.clientSupported =
                def
                    { TLS.supportedCiphers = TLS.ciphersuite_strong
                    , TLS.supportedVersions = versions
                    }
            , TLS.clientShared = def
            , TLS.clientHooks =
                def
                    { TLS.onServerCertificate = \_ _ _ _ -> pure []
                    }
            }

-- | Create a client socket connected to localhost:port
makeClientSocket :: Int -> IO TLS.Backend
makeClientSocket port = do
    sock <- Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol
    Socket.connect sock (Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (127, 0, 0, 1)))
    pure $
        TLS.Backend
            { TLS.backendFlush = pure ()
            , TLS.backendClose = Socket.close sock
            , TLS.backendSend = SocketBS.sendAll sock
            , TLS.backendRecv = SocketBS.recv sock
            }

-- | Bind to a free port
bindFreePort :: IO (Socket.Socket, Int)
bindFreePort = do
    sock <- Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol
    Socket.setSocketOption sock Socket.ReuseAddr 1
    Socket.bind sock (Socket.SockAddrInet 0 (Socket.tupleToHostAddress (127, 0, 0, 1)))
    Socket.listen sock 5
    port <- Socket.socketPort sock
    pure (sock, fromIntegral port)