packages feed

test-certs-0.1.0.0: src/Test/Certs/Temp.hs

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}

{- |
Module      : Test.Certs.Temp
Copyright   : (c) 2023 Tim Emiola
Maintainer  : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD3

Enables configuration and generation of temporary certificates
-}
module Test.Certs.Temp
  ( -- * generate certificates
    withCertPaths
  , withCertFilenames
  , withCertPathsInTmp
  , withCertPathsInTmp'
  , generateAndStore

    -- * configuration
  , Config (..)
  , defaultConfig

    -- * certificate filenames
  , CertPaths (..)
  , keyPath
  , certificatePath
  )
where

import qualified Data.ByteString as BS
import Data.Maybe (catMaybes)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Time (UTCTime, addUTCTime, getCurrentTime, nominalDay)
import Numeric.Natural (Natural)
import qualified OpenSSL.PEM as SSL
import qualified OpenSSL.RSA as SSL
import qualified OpenSSL.Random as SSL
import qualified OpenSSL.X509 as SSL
import System.FilePath ((</>))
import System.IO.Temp (getCanonicalTemporaryDirectory, withTempDirectory)


-- | Specifies the location to write the temporary certificates
data CertPaths = CertPaths
  { cpKey :: !FilePath
  -- ^ the basename of the private key file
  , cpCert :: !FilePath
  -- ^ the basename of the certificate file
  , cpDir :: !FilePath
  -- ^ the directory containing the certificate files
  }
  deriving (Eq, Show)


-- | The path of the generated key file
keyPath :: CertPaths -> FilePath
keyPath cp = cpDir cp </> cpKey cp


-- | The path of the generated certificate file
certificatePath :: CertPaths -> FilePath
certificatePath cp = cpDir cp </> cpCert cp


{- | A @CertPaths using the default basenames for the certificate files
@cpKey@ is @key.pem@
@cpCert@ is @certificate.pem@
-}
defaultBasenames :: FilePath -> CertPaths
defaultBasenames cpDir =
  CertPaths
    { cpDir
    , cpKey = "key.pem"
    , cpCert = "certificate.pem"
    }


-- | Configure some details of the generated certificates
data Config = Config
  { cCommonName :: !Text
  -- ^ the certificate common name
  , cDurationDays :: !Natural
  -- ^ the certificate's duration in days
  , cProvince :: !(Maybe Text)
  , cCity :: !(Maybe Text)
  , cOrganization :: !(Maybe Text)
  , cCountry :: !(Maybe Text)
  }
  deriving (Eq, Show)


-- | A default value for @'Config'@: CN=localhost, duration is 365 days.
defaultConfig :: Config
defaultConfig =
  Config
    { cCountry = Nothing
    , cProvince = Nothing
    , cCity = Nothing
    , cOrganization = Nothing
    , cCommonName = "localhost"
    , cDurationDays = 365
    }


asDistinguished :: Config -> [(String, String)]
asDistinguished c =
  let dnMaybe k f = (fmap ((k,) . Text.unpack) . f)
   in catMaybes
        [ dnMaybe "C" cCountry c
        , dnMaybe "ST" cProvince c
        , dnMaybe "L" cCity c
        , dnMaybe "O" cOrganization c
        , dnMaybe "CN" (Just . cCommonName) c
        ]


validityNow :: Natural -> IO (UTCTime, UTCTime)
validityNow ndays = do
  start <- getCurrentTime
  let end = (nominalDay * fromIntegral ndays) `addUTCTime` start
  pure (start, end)


testKeySize :: Int
testKeySize = 4096


testExponent :: Integer
testExponent = 257


genCerts :: Config -> IO (String, String)
genCerts config = do
  -- set up values to use in the certificate fields
  let mkSerialNum = BS.foldl (\a w -> a * 256 + fromIntegral w) 0
      distinguished = asDistinguished config
  serialNumber <- mkSerialNum <$> SSL.randBytes 8
  (start, end) <- validityNow $ cDurationDays config

  -- generate an RSA key pair
  kp <- SSL.generateRSAKey' testKeySize $ fromIntegral testExponent

  -- create and sign a certificate using the private key of the key pair
  cert <- SSL.newX509
  SSL.setVersion cert 2
  SSL.setSerialNumber cert serialNumber
  SSL.setIssuerName cert distinguished
  SSL.setSubjectName cert distinguished
  SSL.setNotBefore cert start
  SSL.setNotAfter cert end
  SSL.setPublicKey cert kp
  SSL.signX509 cert kp Nothing

  -- the PEM representation of the private key
  privString <- SSL.writePKCS8PrivateKey kp Nothing

  -- the PEM representation of the certificate
  certString <- SSL.writeX509 cert

  pure (certString, privString)


storeCerts :: CertPaths -> String -> String -> IO ()
storeCerts cp rsaKey signedCert = do
  writeFile (keyPath cp) rsaKey
  writeFile (certificatePath cp) signedCert


-- | Generate and store certificate files as specified as @'CertPaths'@
generateAndStore :: CertPaths -> Config -> IO ()
generateAndStore cp config = do
  (certificate, privKey) <- genCerts config
  storeCerts cp privKey certificate


-- | Like 'withCertPaths', but allows the @CertPath@ filenames to be specified
withCertFilenames
  :: (FilePath -> CertPaths)
  -> FilePath
  -> Config
  -> (CertPaths -> IO a)
  -> IO a
withCertFilenames mkCertPath parentDir config useCerts =
  withTempDirectory parentDir "temp-certs" $ \tmpDir -> do
    let certPaths = mkCertPath tmpDir
    generateAndStore certPaths config
    useCerts certPaths


{- | Create certificates in a temporary directory below @parentDir@, specify the
locations using @CertPaths@, use them, then delete them
-}
withCertPaths :: FilePath -> Config -> (CertPaths -> IO a) -> IO a
withCertPaths = withCertFilenames defaultBasenames


-- | Like 'withCertPaths' with the system @TEMP@ dir as the @parentDir@
withCertPathsInTmp :: Config -> (CertPaths -> IO a) -> IO a
withCertPathsInTmp config action = do
  parentDir <- getCanonicalTemporaryDirectory
  withCertPaths parentDir config action


-- | Like 'withCertPathsInTmp' using a default @'Config'@
withCertPathsInTmp' :: (CertPaths -> IO a) -> IO a
withCertPathsInTmp' = withCertPathsInTmp defaultConfig