171 lines
5.5 KiB
Haskell
171 lines
5.5 KiB
Haskell
{-
|
|
©AngelaMos | 2026
|
|
TLS.hs
|
|
-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE RecordWildCards #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
|
|
module Aenebris.TLS
|
|
( TLSSettings
|
|
, createTLSSettings
|
|
, createSNISettings
|
|
, validateCertificate
|
|
, CertificateError(..)
|
|
, strongCipherSuites
|
|
) where
|
|
|
|
import Control.Exception (SomeException, try)
|
|
import qualified Data.ByteString.Lazy as LBS
|
|
import Data.Default.Class (def)
|
|
import Data.Map.Strict (Map)
|
|
import qualified Data.Map.Strict as Map
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.X509 (SignedCertificate)
|
|
import Data.X509.File (readSignedObject)
|
|
import qualified Network.TLS as TLS
|
|
import qualified Network.TLS.Extra.Cipher as Cipher
|
|
import Network.Wai.Handler.WarpTLS
|
|
import System.Directory (doesFileExist)
|
|
import System.IO (hPutStrLn, stderr)
|
|
|
|
httpsRequiredMessage :: LBS.ByteString
|
|
httpsRequiredMessage = "This server requires HTTPS"
|
|
|
|
data CertificateError
|
|
= CertFileNotFound FilePath
|
|
| KeyFileNotFound FilePath
|
|
| InvalidCertificate FilePath String
|
|
| InvalidKey FilePath String
|
|
deriving (Show, Eq)
|
|
|
|
createTLSSettings
|
|
:: FilePath
|
|
-> FilePath
|
|
-> IO (Either CertificateError TLSSettings)
|
|
createTLSSettings certFile keyFile = do
|
|
certExists <- doesFileExist certFile
|
|
keyExists <- doesFileExist keyFile
|
|
if not certExists
|
|
then pure (Left (CertFileNotFound certFile))
|
|
else if not keyExists
|
|
then pure (Left (KeyFileNotFound keyFile))
|
|
else do
|
|
result <- try $ TLS.credentialLoadX509 certFile keyFile
|
|
case result of
|
|
Left (err :: SomeException) ->
|
|
pure (Left (InvalidCertificate certFile (show err)))
|
|
Right (Left err) ->
|
|
pure (Left (InvalidCertificate certFile err))
|
|
Right (Right _) ->
|
|
pure (Right (configureTLS certFile keyFile))
|
|
|
|
configureTLS :: FilePath -> FilePath -> TLSSettings
|
|
configureTLS certFile keyFile = (tlsSettings certFile keyFile)
|
|
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
|
|
, tlsCiphers = strongCipherSuites
|
|
, onInsecure = DenyInsecure httpsRequiredMessage
|
|
}
|
|
|
|
createSNISettings
|
|
:: [(Text, FilePath, FilePath)]
|
|
-> FilePath
|
|
-> FilePath
|
|
-> IO (Either CertificateError TLSSettings)
|
|
createSNISettings domains defaultCert defaultKey = do
|
|
defaultCertOk <- doesFileExist defaultCert
|
|
defaultKeyOk <- doesFileExist defaultKey
|
|
if not defaultCertOk
|
|
then pure (Left (CertFileNotFound defaultCert))
|
|
else if not defaultKeyOk
|
|
then pure (Left (KeyFileNotFound defaultKey))
|
|
else do
|
|
validations <- mapM validateDomainCert domains
|
|
case sequence validations of
|
|
Left err -> pure (Left err)
|
|
Right _ -> pure (Right (configureSNI domains defaultCert defaultKey))
|
|
|
|
configureSNI
|
|
:: [(Text, FilePath, FilePath)]
|
|
-> FilePath
|
|
-> FilePath
|
|
-> TLSSettings
|
|
configureSNI domains defaultCert defaultKey =
|
|
let baseTLS = tlsSettings defaultCert defaultKey
|
|
in baseTLS
|
|
{ tlsAllowedVersions = [TLS.TLS13, TLS.TLS12]
|
|
, tlsCiphers = strongCipherSuites
|
|
, onInsecure = DenyInsecure httpsRequiredMessage
|
|
, tlsServerHooks = def
|
|
{ TLS.onServerNameIndication = \mHostname -> case mHostname of
|
|
Nothing -> credentialsOrDefault defaultCert defaultKey
|
|
Just hostname ->
|
|
sniCallback domains defaultCert defaultKey hostname
|
|
}
|
|
}
|
|
|
|
validateDomainCert
|
|
:: (Text, FilePath, FilePath) -> IO (Either CertificateError ())
|
|
validateDomainCert (_domain, certFile, keyFile) = do
|
|
certExists <- doesFileExist certFile
|
|
keyExists <- doesFileExist keyFile
|
|
if not certExists
|
|
then pure (Left (CertFileNotFound certFile))
|
|
else if not keyExists
|
|
then pure (Left (KeyFileNotFound keyFile))
|
|
else pure (Right ())
|
|
|
|
sniCallback
|
|
:: [(Text, FilePath, FilePath)]
|
|
-> FilePath
|
|
-> FilePath
|
|
-> String
|
|
-> IO TLS.Credentials
|
|
sniCallback domains defaultCert defaultKey hostname =
|
|
let domainMap :: Map Text (FilePath, FilePath)
|
|
domainMap = Map.fromList [(d, (c, k)) | (d, c, k) <- domains]
|
|
in case Map.lookup (T.pack hostname) domainMap of
|
|
Nothing -> credentialsOrDefault defaultCert defaultKey
|
|
Just (certFile, keyFile) ->
|
|
credentialsOrDefault certFile keyFile
|
|
|
|
credentialsOrDefault :: FilePath -> FilePath -> IO TLS.Credentials
|
|
credentialsOrDefault certFile keyFile = do
|
|
result <- TLS.credentialLoadX509 certFile keyFile
|
|
case result of
|
|
Left err -> do
|
|
hPutStrLn stderr $
|
|
"TLS: failed to load credential at "
|
|
<> certFile <> " (" <> err <> "); SNI handler returns empty credentials"
|
|
pure (TLS.Credentials [])
|
|
Right credential ->
|
|
pure (TLS.Credentials [credential])
|
|
|
|
validateCertificate
|
|
:: FilePath -> IO (Either CertificateError [SignedCertificate])
|
|
validateCertificate certFile = do
|
|
exists <- doesFileExist certFile
|
|
if not exists
|
|
then pure (Left (CertFileNotFound certFile))
|
|
else do
|
|
result <- try $ readSignedObject certFile
|
|
case result of
|
|
Left (err :: SomeException) ->
|
|
pure (Left (InvalidCertificate certFile (show err)))
|
|
Right certs ->
|
|
pure (Right certs)
|
|
|
|
strongCipherSuites :: [TLS.Cipher]
|
|
strongCipherSuites =
|
|
[ Cipher.cipher13_AES_128_GCM_SHA256
|
|
, Cipher.cipher13_AES_256_GCM_SHA384
|
|
, Cipher.cipher13_CHACHA20_POLY1305_SHA256
|
|
, Cipher.cipher_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
|
, Cipher.cipher_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
|
, Cipher.cipher_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
|
, Cipher.cipher_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
|
, Cipher.cipher_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
|
|
, Cipher.cipher_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
|
|
]
|