diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,9 @@
+# Revision history for test-certs
+
+`test-certs` uses [PVP Versioning][1].
+
+## 0.1.0.0 -- 2024-03-24
+
+* Initial version.
+
+[1]: https://pvp.haskell.org
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2023, Tim Emiola
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Tim Emiola nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,4 @@
+import Distribution.Simple
+
+
+main = defaultMain
diff --git a/src/Test/Certs/Temp.hs b/src/Test/Certs/Temp.hs
new file mode 100644
--- /dev/null
+++ b/src/Test/Certs/Temp.hs
@@ -0,0 +1,210 @@
+{-# 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
diff --git a/test-certs.cabal b/test-certs.cabal
new file mode 100644
--- /dev/null
+++ b/test-certs.cabal
@@ -0,0 +1,60 @@
+cabal-version:      3.0
+name:               test-certs
+version:            0.1.0.0
+synopsis:           create temporary SSL certificates in tests
+description:
+  Its functions generate the certificates as files in a temporary directory.
+
+  * Note: this package depends on [HsOpenSSL](https://hackage.haskell.org/package/HsOpenSSL).
+  * It expects the openssl system libraries to be available on your system, this
+    is usually the case on most modern linux distributions.
+
+  See the [README](https://github.com/adetokunbo/test-certs#readme) for a usage example.
+
+license:            BSD-3-Clause
+license-file:       LICENSE
+author:             Tim Emiola
+maintainer:         adetokunbo@emio.la
+category:           Testing
+homepage:           https://github.com/adetokunbo/test-certs#readme
+bug-reports:        https://github.com/adetokunbo/test-certs/issues
+build-type:         Simple
+extra-source-files: ChangeLog.md
+
+source-repository head
+  type:     git
+  location: https://github.com/adetokunbo/test-certs.git
+
+library
+  exposed-modules:  Test.Certs.Temp
+  hs-source-dirs:   src
+  build-depends:
+    , base        >=4.10     && <5
+    , bytestring  >=0.10.8.2 && <0.11  || >=0.11.3 && <0.13
+    , filepath    >=1.4      && <1.4.3 || >=1.5.1  && <1.6
+    , HsOpenSSL   >=0.11.7   && <0.12
+    , temporary   >=1.2      && <1.5
+    , text        >=1.2.3    && <2.2
+    , time        >=1.10     && <1.13
+
+  default-language: Haskell2010
+  ghc-options:
+    -Wall -Wincomplete-uni-patterns -Wpartial-fields -fwarn-tabs
+
+test-suite test
+  type:             exitcode-stdio-1.0
+  main-is:          Spec.hs
+  hs-source-dirs:   test
+  other-modules:    Certs.TempSpec
+  default-language: Haskell2010
+  ghc-options:
+    -threaded -rtsopts -with-rtsopts=-N -Wall -fwarn-tabs
+    -funbox-strict-fields -optc-O3 -optc-ffast-math
+
+  build-depends:
+    , base
+    , directory   >=1.3 && <1.4
+    , hspec       >=2.1
+    , QuickCheck
+    , test-certs
+    , tls
diff --git a/test/Certs/TempSpec.hs b/test/Certs/TempSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Certs/TempSpec.hs
@@ -0,0 +1,71 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : Certs.TempSpec
+Copyright   : (c) 2023 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+-}
+module Certs.TempSpec (spec) where
+
+import Data.Either (isRight)
+import Network.TLS (credentialLoadX509)
+import System.Directory (getCurrentDirectory)
+import Test.Certs.Temp
+import Test.Hspec
+
+
+spec :: Spec
+spec = describe "Test.Certs.Temp" $ do
+  context "loading certificates generated via" $ do
+    context "withCertsPathInTmp" $ do
+      it "should succeed withCertsPathsInTmp" $ do
+        shouldGenerateCerts $ withCertPathsInTmp testConfig
+
+    context "withCertsPathInTmp'" $ do
+      it "should succeed" $ do
+        shouldGenerateCerts withCertPathsInTmp'
+
+    context "withCertsPath" $ do
+      it "should succeed" $ do
+        cwd <- getCurrentDirectory
+        shouldGenerateCerts $ withCertPaths cwd testConfig
+
+    context "withCertFilenames" $ do
+      it "should succeed" $ do
+        cwd <- getCurrentDirectory
+        shouldGenerateCerts $ withCertFilenames altBasenames cwd testConfig
+
+
+shouldGenerateCerts :: ((CertPaths -> IO Bool) -> IO Bool) -> IO ()
+shouldGenerateCerts actionF = actionF hasCertsAt >>= (`shouldBe` True)
+
+
+hasCertsAt :: CertPaths -> IO Bool
+hasCertsAt cp = do
+  let cert = certificatePath cp
+      key = keyPath cp
+  isRight <$> credentialLoadX509 cert key
+
+
+-- | A default value for @'Config'@
+testConfig :: Config
+testConfig =
+  Config
+    { cCountry = Just "JP"
+    , cProvince = Just "Fukuoka"
+    , cCity = Just "Itoshima"
+    , cOrganization = Just "haskell:test-certs"
+    , cCommonName = "localhost"
+    , cDurationDays = 365
+    }
+
+
+altBasenames :: FilePath -> CertPaths
+altBasenames cpDir =
+  CertPaths
+    { cpDir
+    , cpKey = "privkey.pem"
+    , cpCert = "cert.pem"
+    }
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,18 @@
+module Main where
+
+import qualified Certs.TempSpec as Temp
+import System.IO
+  ( BufferMode (..)
+  , hSetBuffering
+  , stderr
+  , stdout
+  )
+import Test.Hspec
+
+
+main :: IO ()
+main = do
+  hSetBuffering stdout NoBuffering
+  hSetBuffering stderr NoBuffering
+  hspec $ do
+    Temp.spec
