diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,1 @@
+# Revision history for rustls
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,46 @@
+# hs-rustls — Rustls for Haskell
+[![CI](https://github.com/amesgen/hs-rustls/workflows/CI/badge.svg)](https://github.com/amesgen/hs-rustls/actions)
+[![Hackage](https://img.shields.io/hackage/v/rustls)](https://hackage.haskell.org/package/rustls)
+
+Haskell bindings for the [Rustls](https://github.com/rustls/rustls) TLS library via [rustls-ffi](https://github.com/rustls/rustls-ffi).
+
+See [the haddocks](https://hackage.haskell.org/package/rustls/docs/Rustls.html) for documentation.
+
+Also see:
+
+ - [http-client-rustls](/http-client-rustls): Make HTTPS requests using [http-client](https://hackage.haskell.org/package/http-client) and Rustls.
+
+## Development
+
+### With Nix
+
+When developing this library, just drop into a Nix shell.
+
+If you want to depend on this library in another package, you have to make sure to include rustls-ffi as a native dependency. You can do so by depending on the `github:amesgen/hs-rustls?dir=nix-rustls` flake, and then using the `nix-rustls.packages.${system}.default` output.
+
+### Without Nix
+
+#### rustls-ffi
+
+Make sure to have [Cargo](https://doc.rust-lang.org/stable/cargo/getting-started/installation.html) installed. Then, clone and install rustls-ffi:
+
+```bash
+git clone https://github.com/rustls/rustls-ffi
+cd rustls-ffi
+make DESTDIR=/path/to/some/dir install
+```
+
+Then, in a `cabal.project.local`, add these lines:
+
+```cabal
+extra-include-dirs: /path/to/some/dir/include
+extra-lib-dirs:     /path/to/some/dir/lib
+```
+
+With this, Cabal should be able to find the rustls-ffi native library.
+
+> Note: This process might become less manual if sth like [haskell/cabal#7906](https://github.com/haskell/cabal/issues/7906) lands in Cabal.
+
+#### Testing
+
+When running the tests in this repo, you have to have [minica](https://github.com/jsha/minica) and [miniserve](https://github.com/svenstaro/miniserve) installed.
diff --git a/cbits/hs_rustls.h b/cbits/hs_rustls.h
new file mode 100644
--- /dev/null
+++ b/cbits/hs_rustls.h
@@ -0,0 +1,20 @@
+#ifndef HS_RUSTLS_H
+#define HS_RUSTLS_H
+
+#include "rustls.h"
+
+static inline void hs_rustls_version(rustls_str *str);
+static inline void hs_rustls_version(rustls_str *str) {
+  *str = rustls_version();
+}
+
+static inline void hs_rustls_supported_ciphersuite_get_name(
+    const struct rustls_supported_ciphersuite *supported_ciphersuite,
+    rustls_str *str);
+static inline void hs_rustls_supported_ciphersuite_get_name(
+    const struct rustls_supported_ciphersuite *supported_ciphersuite,
+    rustls_str *str) {
+  *str = rustls_supported_ciphersuite_get_name(supported_ciphersuite);
+}
+
+#endif
diff --git a/rustls.cabal b/rustls.cabal
new file mode 100644
--- /dev/null
+++ b/rustls.cabal
@@ -0,0 +1,84 @@
+cabal-version: 2.4
+name: rustls
+version: 0.0.0.0
+
+synopsis: TLS bindings for Rustls
+description:
+  TLS bindings for [Rustls](https://github.com/rustls/rustls)
+  via [rustls-ffi](https://github.com/rustls/rustls-ffi).
+category: Cryptography, Network
+
+author: amesgen
+maintainer: amesgen@amesgen.de
+homepage: https://github.com/amesgen/hs-rustls/tree/main/rustls
+bug-reports: https://github.com/amesgen/hs-rustls/issues
+license: CC0-1.0
+license-file: LICENSE
+
+extra-source-files:
+  cbits/hs_rustls.h
+  README.md
+  CHANGELOG.md
+
+source-repository head
+  location: https://github.com/amesgen/hs-rustls
+  type: git
+
+flag derive-storable-plugin
+  description: Use derive-storable-plugin
+  default: True
+  manual: True
+
+common commons
+  default-language: Haskell2010
+  ghc-options: -Wall -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -Wno-name-shadowing -Wno-unticked-promoted-constructors -fhide-source-paths
+  if impl(ghc >= 8.10)
+    ghc-options: -Wunused-packages
+  default-extensions: BangPatterns BlockArguments CApiFFI CPP DataKinds DeriveAnyClass DeriveGeneric DerivingStrategies ExistentialQuantification GeneralizedNewtypeDeriving KindSignatures LambdaCase MultiWayIf NamedFieldPuns OverloadedStrings PatternSynonyms RecordWildCards RoleAnnotations ScopedTypeVariables StrictData TupleSections TypeApplications ViewPatterns
+
+library
+  import: commons
+  ghc-options: -Wmissing-export-lists
+  hs-source-dirs: src
+  exposed-modules:
+    Rustls
+    Rustls.Internal
+    Rustls.Internal.FFI
+  build-depends:
+      base >= 4.12 && < 5
+    , bytestring >= 0.10 && < 0.12
+    , text ^>= 1.2 || ^>= 2.0
+    , derive-storable ^>= 0.3
+    , transformers >= 0.5.6 && < 0.7
+    , resourcet ^>= 1.2
+    , network ^>= 3.1
+  if flag(derive-storable-plugin)
+    build-depends: derive-storable-plugin
+    cpp-options: -DDERIVE_STORABLE_PLUGIN
+  extra-libraries: rustls
+  include-dirs: cbits
+
+test-suite tasty
+  import: commons
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  hs-source-dirs: test
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base
+    , rustls
+    , tasty >= 1.3 && < 1.5
+    , tasty-hunit ^>= 0.10
+    , tasty-hedgehog >= 1.0 && < 1.3
+    , hedgehog >= 1.0 && < 1.2
+    , text
+    , bytestring
+    , containers ^>= 0.6
+    , transformers
+    , resourcet
+    , async ^>= 2.2
+    , stm ^>= 2.5
+    , process ^>= 1.6
+    , filepath ^>= 1.4
+    , directory ^>= 1.3
+    , temporary ^>= 1.3
diff --git a/src/Rustls.hs b/src/Rustls.hs
new file mode 100644
--- /dev/null
+++ b/src/Rustls.hs
@@ -0,0 +1,577 @@
+-- | TLS bindings for [Rustls](https://github.com/rustls/rustls) via
+-- [rustls-ffi](https://github.com/rustls/rustls-ffi).
+--
+-- See the [README on GitHub](https://github.com/amesgen/hs-rustls/tree/main/rustls)
+-- for setup instructions.
+--
+-- Currently, most of the functionality exposed by rustls-ffi is available,
+-- while rustls-ffi is still missing some more niche Rustls features.
+--
+-- Also see [http-client-rustls](https://hackage.haskell.org/package/http-client-rustls)
+-- for making HTTPS requests using
+-- [http-client](https://hackage.haskell.org/package/http-client) and Rustls.
+--
+-- == Client example
+--
+-- Suppose you have alread opened a 'Network.Socket.Socket' to @example.org@,
+-- port 443 (see e.g. the examples at "Network.Socket"). This small example
+-- showcases how to perform a simple HTTP GET request:
+--
+-- >>> :set -XOverloadedStrings
+-- >>> import qualified Rustls
+-- >>> import Network.Socket (Socket)
+-- >>> import Data.Acquire (withAcquire)
+-- >>> :{
+-- example :: Socket -> IO ()
+-- example socket = do
+--   -- It is encouraged to share a single `clientConfig` when creating multiple
+--   -- TLS connections.
+--   clientConfig <-
+--     Rustls.buildClientConfig $ Rustls.defaultClientConfigBuilder roots
+--   let newConnection =
+--         Rustls.newClientConnection socket clientConfig "example.org"
+--   withAcquire newConnection $ \conn -> do
+--     Rustls.writeBS conn "GET /"
+--     recv <- Rustls.readBS conn 1000 -- max number of bytes to read
+--     print recv
+--   where
+--     -- For now, rustls-ffi does not provide a built-in way to access
+--     -- the OS certificate store.
+--     roots = Rustls.ClientRootsFromFile "/etc/ssl/certs/ca-certificates.crt"
+-- :}
+--
+-- == Using 'Acquire'
+--
+-- Some API functions (like 'newClientConnection' and 'newServerConnection')
+-- return an 'Acquire' from
+-- [resourcet](https://hackage.haskell.org/package/resourcet), as it is a
+-- convenient abstraction for exposing a value that should be consumed in a
+-- "bracketed" manner.
+--
+-- Usually, it can be used via 'Data.Acquire.with' or 'withAcquire', or via
+-- 'allocateAcquire' when a 'Control.Monad.Trans.Resource.MonadResource'
+-- constraint is available. If you really need the extra flexibility, you can
+-- also access separate @open…@ and @close…@ functions by reaching for
+-- "Data.Acquire.Internal".
+module Rustls
+  ( -- * Client
+
+    -- ** Builder
+    ClientConfigBuilder (..),
+    defaultClientConfigBuilder,
+    ClientRoots (..),
+    PEMCertificates (..),
+
+    -- ** Config
+    ClientConfig,
+    clientConfigLogCallback,
+    buildClientConfig,
+
+    -- ** Open a connection
+    newClientConnection,
+
+    -- * Server
+
+    -- ** Builder
+    ServerConfigBuilder (..),
+    defaultServerConfigBuilder,
+    ClientCertVerifier (..),
+
+    -- ** Config
+    ServerConfig,
+    serverConfigLogCallback,
+    buildServerConfig,
+
+    -- ** Open a connection
+    newServerConnection,
+
+    -- * Connection
+    Connection,
+    Side (..),
+
+    -- ** Read and write
+    readBS,
+    writeBS,
+
+    -- ** Handshaking
+    handshake,
+    HandshakeQuery,
+    getALPNProtocol,
+    getTLSVersion,
+    getCipherSuite,
+    getSNIHostname,
+    getPeerCertificate,
+
+    -- ** Closing
+    sendCloseNotify,
+
+    -- ** Logging
+    LogCallback,
+    newLogCallback,
+    LogLevel (..),
+
+    -- ** Raw 'Ptr'-based API
+    readPtr,
+    writePtr,
+
+    -- * Misc
+    version,
+
+    -- ** Backend
+    Backend (..),
+    ByteStringBackend (..),
+
+    -- ** Types
+    ALPNProtocol (..),
+    CertifiedKey (..),
+    DERCertificate (..),
+    TLSVersion (TLS12, TLS13, unTLSVersion),
+    defaultTLSVersions,
+    allTLSVersions,
+    CipherSuite,
+    cipherSuiteID,
+    showCipherSuite,
+    defaultCipherSuites,
+    allCipherSuites,
+
+    -- ** Exceptions
+    RustlsException,
+    isCertError,
+  )
+where
+
+import Control.Concurrent (forkFinally, killThread)
+import Control.Concurrent.MVar
+import qualified Control.Exception as E
+import Control.Monad (forever, when, (<=<))
+import Control.Monad.IO.Class
+import Control.Monad.Trans.Cont
+import Control.Monad.Trans.Reader
+import Data.Acquire
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Internal as BI
+import qualified Data.ByteString.Unsafe as BU
+import Data.Coerce
+import Data.Foldable (for_)
+import Data.List.NonEmpty (NonEmpty)
+import qualified Data.List.NonEmpty as NE
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Foreign as T
+import Foreign
+import Foreign.C
+import GHC.Generics (Generic)
+import Rustls.Internal
+import Rustls.Internal.FFI (TLSVersion (..))
+import qualified Rustls.Internal.FFI as FFI
+import System.IO (hPutStrLn, stderr)
+import System.IO.Unsafe (unsafePerformIO)
+
+-- $setup
+-- >>> import Control.Monad.IO.Class
+-- >>> import Data.Acquire
+
+-- | Combined version string of Rustls and rustls-ffi.
+--
+-- >>> version
+-- "rustls-ffi/0.9.1/rustls/0.20.4"
+version :: Text
+version = unsafePerformIO $ alloca \strPtr -> do
+  FFI.hsVersion strPtr
+  strToText =<< peek strPtr
+{-# NOINLINE version #-}
+
+peekNonEmpty :: (Storable a, Coercible a b) => Ptr a -> CSize -> NonEmpty b
+peekNonEmpty as len =
+  NE.fromList . coerce . unsafePerformIO $ peekArray (cSizeToInt len) as
+
+-- | All 'TLSVersion's supported by Rustls.
+allTLSVersions :: NonEmpty TLSVersion
+allTLSVersions = peekNonEmpty FFI.allVersions FFI.allVersionsLen
+{-# NOINLINE allTLSVersions #-}
+
+-- | The default 'TLSVersion's used by Rustls. A subset of 'defaultTLSVersions'.
+defaultTLSVersions :: NonEmpty TLSVersion
+defaultTLSVersions = peekNonEmpty FFI.defaultVersions FFI.defaultVersionsLen
+{-# NOINLINE defaultTLSVersions #-}
+
+-- | All 'CipherSuite's supported by Rustls.
+allCipherSuites :: NonEmpty CipherSuite
+allCipherSuites = peekNonEmpty FFI.allCipherSuites FFI.allCipherSuitesLen
+{-# NOINLINE allCipherSuites #-}
+
+-- | The default 'CipherSuite's used by Rustls. A subset of 'allCipherSuites'.
+defaultCipherSuites :: NonEmpty CipherSuite
+defaultCipherSuites = peekNonEmpty FFI.defaultCipherSuites FFI.defaultCipherSuitesLen
+{-# NOINLINE defaultCipherSuites #-}
+
+-- | A 'ClientConfigBuilder' with good defaults.
+defaultClientConfigBuilder :: ClientRoots -> ClientConfigBuilder
+defaultClientConfigBuilder roots =
+  ClientConfigBuilder
+    { clientConfigTLSVersions = [],
+      clientConfigCipherSuites = [],
+      clientConfigRoots = roots,
+      clientConfigALPNProtocols = [],
+      clientConfigEnableSNI = True,
+      clientConfigCertifiedKeys = []
+    }
+
+withCertifiedKeys :: [CertifiedKey] -> ((Ptr (Ptr FFI.CertifiedKey), CSize) -> IO a) -> IO a
+withCertifiedKeys certifiedKeys cb =
+  withMany withCertifiedKey certifiedKeys \certKeys ->
+    withArrayLen certKeys \len ptr -> cb (ptr, intToCSize len)
+  where
+    withCertifiedKey CertifiedKey {..} cb =
+      BU.unsafeUseAsCStringLen certificateChain \(castPtr -> certPtr, intToCSize -> certLen) ->
+        BU.unsafeUseAsCStringLen privateKey \(castPtr -> privPtr, intToCSize -> privLen) ->
+          alloca \certKeyPtr -> do
+            rethrowR =<< FFI.certifiedKeyBuild certPtr certLen privPtr privLen certKeyPtr
+            cb =<< peek certKeyPtr
+
+withALPNProtocols :: [ALPNProtocol] -> ((Ptr FFI.SliceBytes, CSize) -> IO a) -> IO a
+withALPNProtocols bss cb = do
+  withMany withSliceBytes (coerce bss) \bsPtrs ->
+    withArrayLen bsPtrs \len bsPtr -> cb (bsPtr, intToCSize len)
+  where
+    withSliceBytes bs cb =
+      BU.unsafeUseAsCStringLen bs \(castPtr -> buf, intToCSize -> len) ->
+        cb $ FFI.SliceBytes buf len
+
+configBuilderNew ::
+  ( Ptr (Ptr FFI.SupportedCipherSuite) ->
+    CSize ->
+    Ptr TLSVersion ->
+    CSize ->
+    Ptr (Ptr configBuilder) ->
+    IO FFI.Result
+  ) ->
+  [CipherSuite] ->
+  [TLSVersion] ->
+  IO (Ptr configBuilder)
+configBuilderNew configBuilderNewCustom cipherSuites tlsVersions = evalContT do
+  builderPtr <- ContT alloca
+  (cipherSuitesLen, cipherSuitesPtr) <-
+    if null cipherSuites
+      then pure (FFI.defaultCipherSuitesLen, FFI.defaultCipherSuites)
+      else ContT \cb -> withArrayLen (coerce cipherSuites) \len ptr ->
+        cb (intToCSize len, ptr)
+  (tlsVersionsLen, tlsVersionsPtr) <-
+    if null tlsVersions
+      then pure (FFI.defaultVersionsLen, FFI.defaultVersions)
+      else ContT \cb -> withArrayLen tlsVersions \len ptr ->
+        cb (intToCSize len, ptr)
+  liftIO do
+    rethrowR
+      =<< configBuilderNewCustom
+        cipherSuitesPtr
+        cipherSuitesLen
+        tlsVersionsPtr
+        tlsVersionsLen
+        builderPtr
+    peek builderPtr
+
+withRootCertStore :: [PEMCertificates] -> (Ptr FFI.RootCertStore -> IO a) -> IO a
+withRootCertStore certs action =
+  E.bracket FFI.rootCertStoreNew FFI.rootCertStoreFree \store -> do
+    let addPEM bs (fromBool @CBool -> strict) = BU.unsafeUseAsCStringLen bs \(buf, len) ->
+          rethrowR =<< FFI.rootCertStoreAddPEM store (castPtr buf) (intToCSize len) strict
+    for_ certs \case
+      PEMCertificatesStrict bs -> addPEM bs True
+      PEMCertificatesLax bs -> addPEM bs False
+    action store
+
+-- | Build a 'ClientConfigBuilder' into a 'ClientConfig'.
+--
+-- This is a relatively expensive operation, so it is a good idea to share one
+-- 'ClientConfig' when creating multiple 'Connection's.
+buildClientConfig :: MonadIO m => ClientConfigBuilder -> m ClientConfig
+buildClientConfig ClientConfigBuilder {..} = liftIO . E.mask_ $
+  E.bracketOnError
+    ( configBuilderNew
+        FFI.clientConfigBuilderNewCustom
+        clientConfigCipherSuites
+        clientConfigTLSVersions
+    )
+    FFI.clientConfigBuilderFree
+    \builder -> do
+      case clientConfigRoots of
+        ClientRootsFromFile rootsPath ->
+          withCString rootsPath $
+            rethrowR <=< FFI.clientConfigBuilderLoadRootsFromFile builder
+        ClientRootsInMemory certs ->
+          withRootCertStore certs $ rethrowR <=< FFI.clientConfigBuilderUseRoots builder
+      withALPNProtocols clientConfigALPNProtocols \(alpnPtr, len) ->
+        rethrowR =<< FFI.clientConfigBuilderSetALPNProtocols builder alpnPtr len
+      FFI.clientConfigBuilderSetEnableSNI builder (fromBool @CBool clientConfigEnableSNI)
+      withCertifiedKeys clientConfigCertifiedKeys \(ptr, len) ->
+        rethrowR =<< FFI.clientConfigBuilderSetCertifiedKey builder ptr len
+      let clientConfigLogCallback = Nothing
+      clientConfigPtr <-
+        newForeignPtr FFI.clientConfigFree =<< FFI.clientConfigBuilderBuild builder
+      pure ClientConfig {..}
+
+-- | Build a 'ServerConfigBuilder' into a 'ServerConfig'.
+--
+-- This is a relatively expensive operation, so it is a good idea to share one
+-- 'ServerConfig' when creating multiple 'Connection's.
+buildServerConfig :: MonadIO m => ServerConfigBuilder -> m ServerConfig
+buildServerConfig ServerConfigBuilder {..} = liftIO . E.mask_ $
+  E.bracketOnError
+    ( configBuilderNew
+        FFI.serverConfigBuilderNewCustom
+        serverConfigCipherSuites
+        serverConfigTLSVersions
+    )
+    FFI.serverConfigBuilderFree
+    \builder -> do
+      withALPNProtocols serverConfigALPNProtocols \(alpnPtr, len) ->
+        rethrowR =<< FFI.serverConfigBuilderSetALPNProtocols builder alpnPtr len
+      rethrowR
+        =<< FFI.serverConfigBuilderSetIgnoreClientOrder
+          builder
+          (fromBool @CBool serverConfigIgnoreClientOrder)
+      withCertifiedKeys (NE.toList serverConfigCertifiedKeys) \(ptr, len) ->
+        rethrowR =<< FFI.serverConfigBuilderSetCertifiedKeys builder ptr len
+      let setBuilderCCV certs ccvNew ccvFree setCCV =
+            withRootCertStore certs \roots ->
+              E.bracket (ccvNew roots) ccvFree $ setCCV builder
+      for_ serverConfigClientCertVerifier \case
+        ClientCertVerifier certs -> do
+          setBuilderCCV
+            certs
+            FFI.clientCertVerifierNew
+            FFI.clientCertVerifierFree
+            FFI.serverConfigBuilderSetClientVerifier
+        ClientCertVerifierOptional certs -> do
+          setBuilderCCV
+            certs
+            FFI.clientCertVerifierOptionalNew
+            FFI.clientCertVerifierOptionalFree
+            FFI.serverConfigBuilderSetClientVerifierOptional
+      serverConfigPtr <-
+        newForeignPtr FFI.serverConfigFree =<< FFI.serverConfigBuilderBuild builder
+      let serverConfigLogCallback = Nothing
+      pure ServerConfig {..}
+
+-- | A 'ServerConfigBuilder' with good defaults.
+defaultServerConfigBuilder :: NonEmpty CertifiedKey -> ServerConfigBuilder
+defaultServerConfigBuilder certifiedKeys =
+  ServerConfigBuilder
+    { serverConfigCertifiedKeys = certifiedKeys,
+      serverConfigTLSVersions = [],
+      serverConfigCipherSuites = [],
+      serverConfigALPNProtocols = [],
+      serverConfigIgnoreClientOrder = False,
+      serverConfigClientCertVerifier = Nothing
+    }
+
+-- | Allocate a new logging callback, taking a 'LogLevel' and a message.
+--
+-- 🚫 Make sure that its lifetime encloses those of the 'Connection's which you
+-- configured to use it.
+newLogCallback :: (LogLevel -> Text -> IO ()) -> Acquire LogCallback
+newLogCallback cb = fmap LogCallback . flip mkAcquire freeHaskellFunPtr $
+  FFI.mkLogCallback \_ logParamsPtr -> ignoreExceptions do
+    FFI.LogParams {..} <- peek logParamsPtr
+    let logLevel = case rustlsLogParamsLevel of
+          FFI.LogLevel 1 -> Right LogLevelError
+          FFI.LogLevel 2 -> Right LogLevelWarn
+          FFI.LogLevel 3 -> Right LogLevelInfo
+          FFI.LogLevel 4 -> Right LogLevelDebug
+          FFI.LogLevel 5 -> Right LogLevelTrace
+          FFI.LogLevel l -> Left l
+    case logLevel of
+      Left l -> hPutStrLn stderr $ "invalid Rustls log level: " <> show l
+      Right logLevel -> do
+        msg <- strToText rustlsLogParamsMessage
+        cb logLevel msg `E.catch` \(e :: E.SomeException) ->
+          hPutStrLn stderr $ "Rustls log callback errored: " <> E.displayException e
+
+newConnection ::
+  Backend b =>
+  b ->
+  ForeignPtr config ->
+  Maybe LogCallback ->
+  (Ptr config -> Ptr (Ptr FFI.Connection) -> IO FFI.Result) ->
+  Acquire (Connection side)
+newConnection backend configPtr logCallback connectionNew =
+  mkAcquire acquire release
+  where
+    acquire = do
+      conn <-
+        alloca \connPtrPtr ->
+          withForeignPtr configPtr \cfgPtr -> liftIO do
+            rethrowR =<< connectionNew cfgPtr connPtrPtr
+            peek connPtrPtr
+      ioMsgReq <- newEmptyMVar
+      ioMsgRes <- newEmptyMVar
+      lenPtr <- malloc
+      readWriteCallback <- FFI.mkReadWriteCallback \_ud buf len iPtr -> do
+        putMVar ioMsgRes $ UsingBuffer buf len iPtr
+        Done ioResult <- takeMVar ioMsgReq
+        pure ioResult
+      let freeCallback = freeHaskellFunPtr readWriteCallback
+          interact = forever do
+            Request readOrWrite <- takeMVar ioMsgReq
+            let readOrWriteTls = case readOrWrite of
+                  Read -> FFI.connectionReadTls
+                  Write -> FFI.connectionWriteTls
+            _ <- readOrWriteTls conn readWriteCallback nullPtr lenPtr
+            putMVar ioMsgRes DoneFFI
+      interactThread <- forkFinally interact (const freeCallback)
+      for_ logCallback $ FFI.connectionSetLogCallback conn . unLogCallback
+      Connection <$> newMVar Connection' {..}
+    release (Connection c) = do
+      Just Connection' {..} <- tryTakeMVar c
+      FFI.connectionFree conn
+      free lenPtr
+      killThread interactThread
+
+-- | Initialize a TLS connection as a client.
+newClientConnection ::
+  Backend b =>
+  b ->
+  ClientConfig ->
+  -- | Hostname.
+  Text ->
+  Acquire (Connection Client)
+newClientConnection b ClientConfig {..} hostname =
+  newConnection b clientConfigPtr clientConfigLogCallback \configPtr connPtrPtr ->
+    withCString (T.unpack hostname) \hostnamePtr ->
+      FFI.clientConnectionNew configPtr hostnamePtr connPtrPtr
+
+-- | Initialize a TLS connection as a server.
+newServerConnection ::
+  Backend b =>
+  b ->
+  ServerConfig ->
+  Acquire (Connection Server)
+newServerConnection b ServerConfig {..} =
+  newConnection b serverConfigPtr serverConfigLogCallback FFI.serverConnectionNew
+
+-- | Ensure that the connection is handshaked. It is only necessary to call this
+-- if you want to obtain connection information. You can do so by providing a
+-- 'HandshakeQuery'.
+--
+-- >>> :{
+-- getALPNAndTLSVersion ::
+--   MonadIO m =>
+--   Connection side ->
+--   m (Maybe ALPNProtocol, TLSVersion)
+-- getALPNAndTLSVersion conn =
+--   handshake conn $ (,) <$> getALPNProtocol <*> getTLSVersion
+-- :}
+handshake :: MonadIO m => Connection side -> HandshakeQuery side a -> m a
+handshake conn (HandshakeQuery query) = liftIO do
+  withConnection conn \c -> do
+    runTLS c TLSHandshake
+    runReaderT query c
+
+-- | Get the negotiated ALPN protocol, if any.
+getALPNProtocol :: HandshakeQuery side (Maybe ALPNProtocol)
+getALPNProtocol = handshakeQuery \Connection' {conn, lenPtr} ->
+  alloca \bufPtrPtr -> do
+    FFI.connectionGetALPNProtocol conn bufPtrPtr lenPtr
+    bufPtr <- peek bufPtrPtr
+    len <- peek lenPtr
+    !alpn <- B.packCStringLen (castPtr bufPtr, cSizeToInt len)
+    pure $ if B.null alpn then Nothing else Just $ ALPNProtocol alpn
+
+-- | Get the negotiated TLS protocol version.
+getTLSVersion :: HandshakeQuery side TLSVersion
+getTLSVersion = handshakeQuery \Connection' {conn} -> do
+  !ver <- FFI.connectionGetProtocolVersion conn
+  when (unTLSVersion ver == 0) $
+    fail "internal rustls error: no protocol version negotiated"
+  pure ver
+
+-- | Get the negotiated cipher suite.
+getCipherSuite :: HandshakeQuery side CipherSuite
+getCipherSuite = handshakeQuery \Connection' {conn} -> do
+  !cipherSuite <- FFI.connectionGetNegotiatedCipherSuite conn
+  when (cipherSuite == nullPtr) $
+    fail "internal rustls error: no cipher suite negotiated"
+  pure $ CipherSuite cipherSuite
+
+-- | Get the SNI hostname set by the client, if any.
+getSNIHostname :: HandshakeQuery Server (Maybe Text)
+getSNIHostname = handshakeQuery \Connection' {conn, lenPtr} ->
+  let go n = allocaBytes (cSizeToInt n) \bufPtr -> do
+        res <- FFI.serverConnectionGetSNIHostname conn bufPtr n lenPtr
+        if res == FFI.resultInsufficientSize
+          then go (2 * n)
+          else do
+            rethrowR res
+            len <- peek lenPtr
+            !sni <- T.peekCStringLen (castPtr bufPtr, cSizeToInt len)
+            pure $ if T.null sni then Nothing else Just sni
+   in go 16
+
+-- | A DER-encoded certificate.
+newtype DERCertificate = DERCertificate {unDERCertificate :: ByteString}
+  deriving stock (Show, Eq, Ord, Generic)
+
+-- | Get the @i@-th certificate provided by the peer.
+--
+-- Index @0@ is the end entity certificate. Higher indices are certificates in
+-- the chain. Requesting an index higher than what is available returns
+-- 'Nothing'.
+getPeerCertificate :: CSize -> HandshakeQuery side (Maybe DERCertificate)
+getPeerCertificate i = handshakeQuery \Connection' {conn, lenPtr} -> do
+  certPtr <- FFI.connectionGetPeerCertificate conn i
+  if certPtr == nullPtr
+    then pure Nothing
+    else alloca \bufPtrPtr -> do
+      rethrowR =<< FFI.certificateGetDER certPtr bufPtrPtr lenPtr
+      bufPtr <- peek bufPtrPtr
+      len <- cSizeToInt <$> peek lenPtr
+      !bs <- B.packCStringLen (castPtr bufPtr, len)
+      pure $ Just $ DERCertificate bs
+
+-- | Send a @close_notify@ warning alert. This informs the peer that the
+-- connection is being closed.
+sendCloseNotify :: MonadIO m => Connection side -> m ()
+sendCloseNotify conn = liftIO $
+  withConnection conn \c@Connection' {conn} -> do
+    FFI.connectionSendCloseNotify conn
+    runTLS c TLSWrite
+
+-- | Read data from the Rustls 'Connection' into the given buffer.
+readPtr :: MonadIO m => Connection side -> Ptr Word8 -> CSize -> m CSize
+readPtr conn buf len = liftIO $
+  withConnection conn \c@Connection' {..} -> do
+    runTLS c TLSWrite
+    runTLS c TLSRead
+    rethrowR =<< FFI.connectionRead conn buf len lenPtr
+    peek lenPtr
+
+-- | Read data from the Rustls 'Connection' into a 'ByteString'. The result will
+-- not be longer than the given length.
+readBS ::
+  MonadIO m =>
+  Connection side ->
+  -- | Maximum result length. Note that a buffer of this size will be allocated.
+  Int ->
+  m ByteString
+readBS conn maxLen = liftIO $
+  BI.createAndTrim maxLen \buf ->
+    cSizeToInt <$> readPtr conn buf (intToCSize maxLen)
+
+-- | Write data to the Rustls 'Connection' from the given buffer.
+writePtr :: MonadIO m => Connection side -> Ptr Word8 -> CSize -> m CSize
+writePtr conn buf len = liftIO $
+  withConnection conn \c@Connection' {..} -> do
+    rethrowR =<< FFI.connectionWrite conn buf len lenPtr
+    runTLS c TLSWrite
+    peek lenPtr
+
+-- | Write a 'ByteString' to the Rustls 'Connection'.
+writeBS :: MonadIO m => Connection side -> ByteString -> m ()
+writeBS conn bs = liftIO $ BU.unsafeUseAsCStringLen bs go
+  where
+    go (buf, len) = do
+      written <- cSizeToInt <$> writePtr conn (castPtr buf) (intToCSize len)
+      when (written < len) $
+        go (buf `plusPtr` len, len - written)
diff --git a/src/Rustls/Internal.hs b/src/Rustls/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Rustls/Internal.hs
@@ -0,0 +1,397 @@
+{-# OPTIONS_GHC -Wno-missing-export-lists #-}
+
+-- | Internal module, not subject to PVP.
+module Rustls.Internal where
+
+import Control.Concurrent (ThreadId)
+import Control.Concurrent.MVar
+import qualified Control.Exception as E
+import Control.Monad (when)
+import Control.Monad.Trans.Reader
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Unsafe as BU
+import Data.Coerce (coerce)
+import Data.Function (on)
+import Data.Functor (void)
+import Data.List.NonEmpty (NonEmpty)
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Foreign as T
+import Foreign hiding (void)
+import Foreign.C
+import GHC.Generics (Generic)
+import qualified Network.Socket as NS
+import qualified Rustls.Internal.FFI as FFI
+import System.IO.Unsafe (unsafePerformIO)
+
+-- | An ALPN protocol ID. See
+-- <https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids>
+-- for a list of registered IDs.
+newtype ALPNProtocol = ALPNProtocol {unALPNProtocol :: ByteString}
+  deriving stock (Show, Eq, Ord, Generic)
+
+-- | A TLS cipher suite supported by Rustls.
+newtype CipherSuite = CipherSuite (Ptr FFI.SupportedCipherSuite)
+
+-- | Get the IANA value from a cipher suite. The bytes are interpreted in network order.
+--
+-- See <https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-4> for a list.
+cipherSuiteID :: CipherSuite -> Word16
+cipherSuiteID (CipherSuite cipherSuitePtr) =
+  FFI.supportedCipherSuiteGetSuite cipherSuitePtr
+
+instance Eq CipherSuite where
+  (==) = (==) `on` cipherSuiteID
+
+instance Ord CipherSuite where
+  compare = compare `on` cipherSuiteID
+
+-- | Get the text representation of a cipher suite.
+showCipherSuite :: CipherSuite -> Text
+showCipherSuite (CipherSuite cipherSuitePtr) = unsafePerformIO $
+  alloca \strPtr -> do
+    FFI.hsSupportedCipherSuiteGetName cipherSuitePtr strPtr
+    strToText =<< peek strPtr
+
+instance Show CipherSuite where
+  show = T.unpack . showCipherSuite
+
+-- | Rustls client config builder.
+data ClientConfigBuilder = ClientConfigBuilder
+  { -- | Client root certificates.
+    clientConfigRoots :: ClientRoots,
+    -- | Supported 'FFI.TLSVersion's. When empty, good defaults are used.
+    clientConfigTLSVersions :: [FFI.TLSVersion],
+    -- | Supported 'CipherSuite's in order of preference. When empty, good
+    -- defaults are used.
+    clientConfigCipherSuites :: [CipherSuite],
+    -- | ALPN protocols.
+    clientConfigALPNProtocols :: [ALPNProtocol],
+    -- | Whether to enable Server Name Indication. Defaults to 'True'.
+    clientConfigEnableSNI :: Bool,
+    -- | List of 'CertifiedKey's for client authentication.
+    --
+    -- Clients that want to support both ECDSA and RSA certificates will want
+    -- the ECDSA to go first in the list.
+    clientConfigCertifiedKeys :: [CertifiedKey]
+  }
+  deriving stock (Show, Generic)
+
+-- | How to look up root certificates.
+data ClientRoots
+  = -- | Fetch PEM-encoded root certificates from a file.
+    ClientRootsFromFile FilePath
+  | -- | Use in-memory PEM-encoded certificates.
+    ClientRootsInMemory [PEMCertificates]
+  deriving stock (Generic)
+
+instance Show ClientRoots where
+  show _ = "ClientRoots"
+
+-- | In-memory PEM-encoded certificates.
+data PEMCertificates
+  = -- | Syntactically valid PEM-encoded certificates.
+    PEMCertificatesStrict ByteString
+  | -- | PEM-encoded certificates, ignored if syntactically invalid.
+    --
+    -- This may be useful on systems that have syntactically invalid root certificates.
+    PEMCertificatesLax ByteString
+  deriving stock (Show, Generic)
+
+-- | A complete chain of certificates plus a private key for the leaf certificate.
+data CertifiedKey = CertifiedKey
+  { -- | PEM-encoded certificate chain.
+    certificateChain :: ByteString,
+    -- | PEM-encoded private key.
+    privateKey :: ByteString
+  }
+  deriving stock (Generic)
+
+instance Show CertifiedKey where
+  show _ = "CertifiedKey"
+
+-- | Assembled configuration for a Rustls client connection.
+data ClientConfig = ClientConfig
+  { clientConfigPtr :: ForeignPtr FFI.ClientConfig,
+    -- | A logging callback. If it throws an exception, a note will be printed
+    -- to stderr.
+    --
+    -- Note that this is a record selector, so you can use it as a setter:
+    --
+    -- >>> :{
+    -- setLogCallback :: LogCallback -> ClientConfig -> ClientConfig
+    -- setLogCallback logCallback clientConfig =
+    --   clientConfig { clientConfigLogCallback = Just logCallback }
+    -- :}
+    clientConfigLogCallback :: Maybe LogCallback
+  }
+
+-- | How to verify TLS client certificates.
+data ClientCertVerifier
+  = -- | Root certificates used to verify TLS client certificates.
+    ClientCertVerifier [PEMCertificates]
+  | -- | Root certificates used to verify TLS client certificates if present,
+    -- but does not reject clients which provide no certificate.
+    ClientCertVerifierOptional [PEMCertificates]
+  deriving stock (Show, Generic)
+
+-- | Rustls client config builder.
+data ServerConfigBuilder = ServerConfigBuilder
+  { -- | List of 'CertifiedKey's.
+    serverConfigCertifiedKeys :: NonEmpty CertifiedKey,
+    -- | Supported 'FFI.TLSVersion's. When empty, good defaults are
+    -- used.
+    serverConfigTLSVersions :: [FFI.TLSVersion],
+    -- | Supported 'CipherSuite's in order of preference. When empty, good
+    -- defaults are used.
+    serverConfigCipherSuites :: [CipherSuite],
+    -- | ALPN protocols.
+    serverConfigALPNProtocols :: [ALPNProtocol],
+    -- | Ignore the client's ciphersuite order. Defaults to 'False'.
+    serverConfigIgnoreClientOrder :: Bool,
+    -- | Optionally, a client cert verifier.
+    serverConfigClientCertVerifier :: Maybe ClientCertVerifier
+  }
+  deriving stock (Show, Generic)
+
+-- | Assembled configuration for a Rustls server connection.
+data ServerConfig = ServerConfig
+  { serverConfigPtr :: ForeignPtr FFI.ServerConfig,
+    -- | A logging callback. If it throws an exception, a note will be printed
+    -- to stderr.
+    --
+    -- Note that this is a record selector, so you can use it as a setter:
+    --
+    -- >>> :{
+    -- setLogCallback :: LogCallback -> ServerConfig -> ServerConfig
+    -- setLogCallback logCallback serverConfig =
+    --   serverConfig { serverConfigLogCallback = Just logCallback }
+    -- :}
+    serverConfigLogCallback :: Maybe LogCallback
+  }
+
+-- | Rustls log level.
+data LogLevel
+  = LogLevelError
+  | LogLevelWarn
+  | LogLevelInfo
+  | LogLevelDebug
+  | LogLevelTrace
+  deriving stock (Show, Eq, Ord, Enum, Bounded, Generic)
+
+-- | A Rustls connection logging callback.
+newtype LogCallback = LogCallback {unLogCallback :: FunPtr FFI.LogCallback}
+
+-- | A 'Monad' to get TLS connection information via 'Rustls.handshake'.
+newtype HandshakeQuery (side :: Side) a = HandshakeQuery (ReaderT Connection' IO a)
+  deriving newtype (Functor, Applicative, Monad)
+
+type role HandshakeQuery nominal _
+
+handshakeQuery :: (Connection' -> IO a) -> HandshakeQuery side a
+handshakeQuery = coerce
+
+-- | TLS exception thrown by Rustls.
+--
+-- Use 'E.displayException' for a human-friendly representation.
+newtype RustlsException = RustlsException {rustlsErrorCode :: Word32}
+  deriving stock (Show)
+
+instance E.Exception RustlsException where
+  displayException RustlsException {rustlsErrorCode} =
+    unwords
+      [ "Rustls error:",
+        T.unpack (resultMsg (FFI.Result rustlsErrorCode)),
+        "(" <> show rustlsErrorCode <> ")"
+      ]
+
+resultMsg :: FFI.Result -> Text
+resultMsg r = unsafePerformIO $
+  alloca \lenPtr -> allocaBytes (cSizeToInt msgLen) \buf -> do
+    FFI.errorMsg r buf msgLen lenPtr
+    len <- peek lenPtr
+    T.peekCStringLen (buf, cSizeToInt len)
+  where
+    msgLen = 1024 -- a bit pessimistic?
+
+-- | Checks if the given 'RustlsException' represents a certificate error.
+isCertError :: RustlsException -> Bool
+isCertError RustlsException {rustlsErrorCode} =
+  toBool @CBool $ FFI.resultIsCertError (FFI.Result rustlsErrorCode)
+
+rethrowR :: FFI.Result -> IO ()
+rethrowR = \case
+  r | r == FFI.resultOk -> mempty
+  FFI.Result rustlsErrorCode ->
+    E.throwIO $ RustlsException rustlsErrorCode
+
+-- | Underlying data sources for Rustls.
+class Backend b where
+  -- | Read data from the backend into the given buffer.
+  backendRead ::
+    b ->
+    -- | Target buffer pointer.
+    Ptr Word8 ->
+    -- | Target buffer length.
+    CSize ->
+    -- | Amount of bytes read.
+    IO CSize
+
+  -- | Write data from the given buffer to the backend.
+  backendWrite ::
+    b ->
+    -- | Source buffer pointer.
+    Ptr Word8 ->
+    -- | Source buffer length.
+    CSize ->
+    -- | Amount of bytes written.
+    IO CSize
+
+instance Backend NS.Socket where
+  backendRead s buf len =
+    intToCSize <$> NS.recvBuf s buf (cSizeToInt len)
+  backendWrite s buf len =
+    intToCSize <$> NS.sendBuf s buf (cSizeToInt len)
+
+-- | An in-memory 'Backend'.
+data ByteStringBackend = ByteStringBackend
+  { -- | Read a 'ByteString' with the given max length.
+    bsbRead :: Int -> IO ByteString,
+    -- | Write a 'ByteString'.
+    bsbWrite :: ByteString -> IO ()
+  }
+  deriving stock (Generic)
+
+-- | This instance will silently truncate 'ByteString's which are too long.
+instance Backend ByteStringBackend where
+  backendRead ByteStringBackend {bsbRead} buf len = do
+    bs <- bsbRead (cSizeToInt len)
+    BU.unsafeUseAsCStringLen bs \(bsPtr, bsLen) -> do
+      let copyLen = bsLen `min` cSizeToInt len
+      copyBytes buf (castPtr bsPtr) copyLen
+      pure $ intToCSize copyLen
+  backendWrite ByteStringBackend {bsbWrite} buf len = do
+    bsbWrite =<< B.packCStringLen (castPtr buf, cSizeToInt len)
+    pure len
+
+-- | Type-level indicator whether a 'Connection' is client- or server-side.
+data Side = Client | Server
+
+-- | A Rustls connection.
+newtype Connection (side :: Side) = Connection (MVar Connection')
+
+type role Connection nominal
+
+data Connection' = forall b.
+  Backend b =>
+  Connection'
+  { conn :: Ptr FFI.Connection,
+    backend :: b,
+    lenPtr :: Ptr CSize,
+    ioMsgReq :: MVar IOMsgReq,
+    ioMsgRes :: MVar IOMsgRes,
+    interactThread :: ThreadId
+  }
+
+withConnection :: Connection side -> (Connection' -> IO a) -> IO a
+withConnection (Connection c) = withMVar c
+
+data ReadOrWrite = Read | Write
+
+-- GHC will delay async exceptions to (non-interruptible) FFI calls until they
+-- finish. In particular, this means that when a (safe) FFI call invokes a
+-- Haskell callback, it is uncancelable. As usages of this library will most
+-- likely involve actual I/O (which really should be able to be cancelled), we
+-- invoke the respective FFI functions (which will themselves then call back
+-- into Haskell) in a separate thread, and interact with it via message passing
+-- (see the 'IOMsgReq' and 'IOMsgRes' types).
+
+-- | Messages sent to the background thread.
+data IOMsgReq
+  = -- | Request to start a read or a write FFI call from the background thread.
+    -- It should respond with 'UsingBuffer'.
+    Request ReadOrWrite
+  | -- | Notify the background thread that we are done interacting with the
+    -- buffer.
+    Done FFI.IOResult
+
+-- | Messages sent from the background thread.
+data IOMsgRes
+  = -- | Reply with a buffer, either containing the read data, or awaiting a
+    -- write to this buffer.
+    UsingBuffer (Ptr Word8) CSize (Ptr CSize)
+  | -- | Notify that the FFI call finished.
+    DoneFFI
+
+interactTLS :: Connection' -> ReadOrWrite -> IO ()
+interactTLS Connection' {..} readOrWrite = E.uninterruptibleMask \restore -> do
+  putMVar ioMsgReq $ Request readOrWrite
+  UsingBuffer buf len readPtr <- takeMVar ioMsgRes
+  poke readPtr
+    =<< restore (readOrWriteBackend buf len)
+    `E.onException` done FFI.ioResultErr
+  done FFI.ioResultOk
+  where
+    readOrWriteBackend = case readOrWrite of
+      Read -> backendRead backend
+      Write -> backendWrite backend
+    done ioResult = do
+      putMVar ioMsgReq $ Done ioResult
+      DoneFFI <- takeMVar ioMsgRes
+      pure ()
+
+data RunTLSMode = TLSHandshake | TLSRead | TLSWrite
+  deriving (Eq)
+
+runTLS :: Connection' -> RunTLSMode -> IO ()
+runTLS c@Connection' {..} = \case
+  TLSHandshake -> loopWhileTrue do
+    toBool @CBool <$> FFI.connectionIsHandshaking conn >>= \case
+      True -> (||) <$> runWrite <*> runRead
+      False -> pure False
+  TLSRead -> do
+    runTLS c TLSHandshake
+    loopWhileTrue runRead
+  TLSWrite -> do
+    runTLS c TLSHandshake
+    loopWhileTrue runWrite
+  where
+    runRead = do
+      wantsRead <- toBool @CBool <$> FFI.connectionWantsRead conn
+      when wantsRead do
+        interactTLS c Read
+        r <- FFI.connectionProcessNewPackets conn
+        -- try to notify our peer that we encountered a TLS error
+        when (r /= FFI.resultOk) $ ignoreSyncExceptions $ void runWrite
+        rethrowR r
+      pure wantsRead
+
+    runWrite = do
+      wantsWrite <- toBool @CBool <$> FFI.connectionWantsWrite conn
+      when wantsWrite $
+        interactTLS c Write
+      pure wantsWrite
+
+    loopWhileTrue action = do
+      continue <- action
+      when continue $ loopWhileTrue action
+
+cSizeToInt :: CSize -> Int
+cSizeToInt = fromIntegral
+{-# INLINE cSizeToInt #-}
+
+intToCSize :: Int -> CSize
+intToCSize = fromIntegral
+{-# INLINE intToCSize #-}
+
+strToText :: FFI.Str -> IO Text
+strToText (FFI.Str buf len) = T.peekCStringLen (buf, cSizeToInt len)
+
+ignoreExceptions :: IO () -> IO ()
+ignoreExceptions = void . E.try @E.SomeException
+
+ignoreSyncExceptions :: IO () -> IO ()
+ignoreSyncExceptions = E.handle \case
+  (E.fromException -> Just e@(E.SomeAsyncException _)) -> E.throwIO e
+  _ -> pure ()
diff --git a/src/Rustls/Internal/FFI.hs b/src/Rustls/Internal/FFI.hs
new file mode 100644
--- /dev/null
+++ b/src/Rustls/Internal/FFI.hs
@@ -0,0 +1,442 @@
+#if DERIVE_STORABLE_PLUGIN
+{-# OPTIONS_GHC -fplugin=Foreign.Storable.Generic.Plugin #-}
+#endif
+
+-- | Internal module, not subject to PVP.
+module Rustls.Internal.FFI
+  ( -- * Client
+
+    -- ** Config
+    ClientConfig,
+    ClientConfigBuilder,
+    clientConfigBuilderNewCustom,
+    clientConfigBuilderFree,
+    clientConfigBuilderBuild,
+    clientConfigFree,
+    clientConfigBuilderSetALPNProtocols,
+    clientConfigBuilderSetEnableSNI,
+    clientConfigBuilderSetCertifiedKey,
+    clientConfigBuilderLoadRootsFromFile,
+    clientConfigBuilderUseRoots,
+
+    -- ** Connection
+    clientConnectionNew,
+    serverConnectionNew,
+
+    -- * Server
+
+    -- ** Config
+    ServerConfig,
+    ServerConfigBuilder,
+    serverConfigBuilderNewCustom,
+    serverConfigBuilderFree,
+    serverConfigBuilderBuild,
+    serverConfigFree,
+    serverConfigBuilderSetALPNProtocols,
+    serverConfigBuilderSetIgnoreClientOrder,
+    serverConfigBuilderSetCertifiedKeys,
+    ClientCertVerifier,
+    clientCertVerifierNew,
+    clientCertVerifierFree,
+    serverConfigBuilderSetClientVerifier,
+    ClientCertVerifierOptional,
+    clientCertVerifierOptionalNew,
+    clientCertVerifierOptionalFree,
+    serverConfigBuilderSetClientVerifierOptional,
+
+    -- * Certificate stuff
+    CertifiedKey,
+    certifiedKeyBuild,
+    certifiedKeyFree,
+    Certificate,
+    certificateGetDER,
+
+    -- * Connection
+    Connection,
+    connectionFree,
+
+    -- ** Read/write
+    ReadWriteCallback,
+    mkReadWriteCallback,
+
+    -- *** Read
+    connectionWantsRead,
+    connectionRead,
+    connectionReadTls,
+
+    -- *** Write
+    connectionWantsWrite,
+    connectionWrite,
+    connectionWriteTls,
+
+    -- ** Misc
+    connectionProcessNewPackets,
+    connectionIsHandshaking,
+    connectionSendCloseNotify,
+    connectionSetBufferLimit,
+    connectionGetALPNProtocol,
+    connectionGetProtocolVersion,
+    connectionGetNegotiatedCipherSuite,
+    serverConnectionGetSNIHostname,
+    connectionGetPeerCertificate,
+
+    -- ** Logging
+    connectionSetLogCallback,
+    LogCallback,
+    mkLogCallback,
+    LogParams (..),
+    LogLevel (..),
+
+    -- * Misc
+    Str (..),
+    SliceBytes (..),
+    hsVersion,
+    Userdata,
+
+    -- ** 'Result'
+    Result (..),
+    resultIsCertError,
+    errorMsg,
+
+    -- *** Some values
+    resultOk,
+    resultInsufficientSize,
+
+    -- ** 'IOResult'
+    IOResult (..),
+    ioResultOk,
+    ioResultErr,
+
+    -- ** TLS params
+    SupportedCipherSuite,
+    allCipherSuites,
+    allCipherSuitesLen,
+    defaultCipherSuites,
+    defaultCipherSuitesLen,
+    supportedCipherSuiteGetSuite,
+    hsSupportedCipherSuiteGetName,
+    TLSVersion (..),
+    pattern TLS12,
+    pattern TLS13,
+    allVersions,
+    allVersionsLen,
+    defaultVersions,
+    defaultVersionsLen,
+
+    -- ** Root cert store
+    RootCertStore,
+    rootCertStoreNew,
+    rootCertStoreAddPEM,
+    rootCertStoreFree,
+  )
+where
+
+import Data.Word
+import Foreign
+import Foreign.C
+import Foreign.Storable.Generic
+import GHC.Generics (Generic)
+
+-- Misc
+
+data {-# CTYPE "rustls.h" "rustls_str" #-} Str = Str CString CSize
+  deriving stock (Generic)
+  deriving anyclass (GStorable)
+
+data {-# CTYPE "rustls.h" "rustls_slice_bytes" #-} SliceBytes = SliceBytes (Ptr Word8) CSize
+  deriving stock (Generic)
+  deriving anyclass (GStorable)
+
+foreign import capi unsafe "hs_rustls.h hs_rustls_version"
+  hsVersion :: Ptr Str -> IO ()
+
+newtype {-# CTYPE "rustls.h" "rustls_result" #-} Result = Result Word32
+  deriving stock (Show, Eq, Ord)
+
+foreign import capi "rustls.h value RUSTLS_RESULT_OK"
+  resultOk :: Result
+
+foreign import capi "rustls.h value RUSTLS_RESULT_INSUFFICIENT_SIZE"
+  resultInsufficientSize :: Result
+
+foreign import capi unsafe "rustls.h rustls_result_is_cert_error"
+  resultIsCertError :: Result -> CBool
+
+foreign import capi unsafe "rustls.h rustls_error"
+  errorMsg :: Result -> CString -> CSize -> Ptr CSize -> IO ()
+
+newtype {-# CTYPE "rustls.h" "rustls_io_result" #-} IOResult = IOResult CInt
+  deriving stock (Eq)
+
+ioResultOk :: IOResult
+ioResultOk = IOResult 0
+
+ioResultErr :: IOResult
+ioResultErr = IOResult 1
+
+-- | (Unused) userdata.
+data Userdata
+
+-- Client
+
+data {-# CTYPE "rustls.h" "rustls_client_config" #-} ClientConfig
+
+data {-# CTYPE "rustls.h" "rustls_client_config_builder" #-} ClientConfigBuilder
+
+foreign import capi unsafe "rustls.h rustls_client_config_builder_new_custom"
+  clientConfigBuilderNewCustom ::
+    Ptr (Ptr SupportedCipherSuite) ->
+    CSize ->
+    Ptr TLSVersion ->
+    CSize ->
+    Ptr (Ptr ClientConfigBuilder) ->
+    IO Result
+
+foreign import capi unsafe "rustls.h rustls_client_config_builder_free"
+  clientConfigBuilderFree :: Ptr ClientConfigBuilder -> IO ()
+
+foreign import capi unsafe "rustls.h rustls_client_config_builder_build"
+  clientConfigBuilderBuild :: Ptr ClientConfigBuilder -> IO (Ptr ClientConfig)
+
+foreign import capi unsafe "rustls.h &rustls_client_config_free"
+  clientConfigFree :: FinalizerPtr ClientConfig
+
+foreign import capi unsafe "rustls.h rustls_client_connection_new"
+  clientConnectionNew ::
+    Ptr ClientConfig ->
+    -- | Hostname.
+    CString ->
+    Ptr (Ptr Connection) ->
+    IO Result
+
+foreign import capi unsafe "rustls.h rustls_client_config_builder_load_roots_from_file"
+  clientConfigBuilderLoadRootsFromFile :: Ptr ClientConfigBuilder -> CString -> IO Result
+
+data {-# CTYPE "rustls.h" "rustls_root_cert_store" #-} RootCertStore
+
+foreign import capi unsafe "rustls.h rustls_root_cert_store_new"
+  rootCertStoreNew :: IO (Ptr RootCertStore)
+
+foreign import capi unsafe "rustls.h rustls_root_cert_store_add_pem"
+  rootCertStoreAddPEM :: Ptr RootCertStore -> Ptr Word8 -> CSize -> CBool -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_root_cert_store_free"
+  rootCertStoreFree :: Ptr RootCertStore -> IO ()
+
+foreign import capi unsafe "rustls.h rustls_client_config_builder_use_roots"
+  clientConfigBuilderUseRoots :: Ptr ClientConfigBuilder -> Ptr RootCertStore -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_client_config_builder_set_alpn_protocols"
+  clientConfigBuilderSetALPNProtocols :: Ptr ClientConfigBuilder -> Ptr SliceBytes -> CSize -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_client_config_builder_set_enable_sni"
+  clientConfigBuilderSetEnableSNI :: Ptr ClientConfigBuilder -> CBool -> IO ()
+
+foreign import capi unsafe "rustls.h rustls_client_config_builder_set_certified_key"
+  clientConfigBuilderSetCertifiedKey :: Ptr ClientConfigBuilder -> Ptr (Ptr CertifiedKey) -> CSize -> IO Result
+
+-- TODO add callback-based cert validation?
+
+-- Server
+data {-# CTYPE "rustls.h" "rustls_server_config" #-} ServerConfig
+
+data {-# CTYPE "rustls.h" "rustls_server_config_builder" #-} ServerConfigBuilder
+
+foreign import capi unsafe "rustls.h rustls_server_config_builder_new_custom"
+  serverConfigBuilderNewCustom ::
+    Ptr (Ptr SupportedCipherSuite) ->
+    CSize ->
+    Ptr TLSVersion ->
+    CSize ->
+    Ptr (Ptr ServerConfigBuilder) ->
+    IO Result
+
+foreign import capi unsafe "rustls.h rustls_server_config_builder_free"
+  serverConfigBuilderFree :: Ptr ServerConfigBuilder -> IO ()
+
+foreign import capi unsafe "rustls.h rustls_server_config_builder_build"
+  serverConfigBuilderBuild :: Ptr ServerConfigBuilder -> IO (Ptr ServerConfig)
+
+foreign import capi unsafe "rustls.h &rustls_server_config_free"
+  serverConfigFree :: FinalizerPtr ServerConfig
+
+foreign import capi unsafe "rustls.h rustls_server_connection_new"
+  serverConnectionNew :: Ptr ServerConfig -> Ptr (Ptr Connection) -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_server_config_builder_set_alpn_protocols"
+  serverConfigBuilderSetALPNProtocols :: Ptr ServerConfigBuilder -> Ptr SliceBytes -> CSize -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_server_config_builder_set_ignore_client_order"
+  serverConfigBuilderSetIgnoreClientOrder :: Ptr ServerConfigBuilder -> CBool -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_server_config_builder_set_certified_keys"
+  serverConfigBuilderSetCertifiedKeys :: Ptr ServerConfigBuilder -> Ptr (Ptr CertifiedKey) -> CSize -> IO Result
+
+data {-# CTYPE "rustls.h" "rustls_client_cert_verifier" #-} ClientCertVerifier
+
+foreign import capi unsafe "rustls.h rustls_client_cert_verifier_new"
+  clientCertVerifierNew :: Ptr RootCertStore -> IO (Ptr ClientCertVerifier)
+
+foreign import capi unsafe "rustls.h rustls_client_cert_verifier_free"
+  clientCertVerifierFree :: Ptr ClientCertVerifier -> IO ()
+
+foreign import capi unsafe "rustls.h rustls_server_config_builder_set_client_verifier"
+  serverConfigBuilderSetClientVerifier :: Ptr ServerConfigBuilder -> Ptr ClientCertVerifier -> IO ()
+
+data {-# CTYPE "rustls.h" "rustls_client_cert_verifier_optional" #-} ClientCertVerifierOptional
+
+foreign import capi unsafe "rustls.h rustls_client_cert_verifier_optional_new"
+  clientCertVerifierOptionalNew :: Ptr RootCertStore -> IO (Ptr ClientCertVerifierOptional)
+
+foreign import capi unsafe "rustls.h rustls_client_cert_verifier_optional_free"
+  clientCertVerifierOptionalFree :: Ptr ClientCertVerifierOptional -> IO ()
+
+foreign import capi unsafe "rustls.h rustls_server_config_builder_set_client_verifier_optional"
+  serverConfigBuilderSetClientVerifierOptional :: Ptr ServerConfigBuilder -> Ptr ClientCertVerifierOptional -> IO ()
+
+-- add custom session persistence functions?
+
+-- connection
+
+data {-# CTYPE "rustls.h" "rustls_connection" #-} Connection
+
+foreign import capi unsafe "rustls.h rustls_connection_free"
+  connectionFree :: Ptr Connection -> IO ()
+
+type LogCallback = Ptr Userdata -> Ptr LogParams -> IO ()
+
+foreign import ccall "wrapper"
+  mkLogCallback :: LogCallback -> IO (FunPtr LogCallback)
+
+newtype LogLevel = LogLevel CSize
+  deriving stock (Eq)
+  deriving newtype (Storable)
+
+data LogParams = LogParams
+  { rustlsLogParamsLevel :: LogLevel,
+    rustlsLogParamsMessage :: Str
+  }
+  deriving stock (Generic)
+  deriving anyclass (GStorable)
+
+foreign import capi unsafe "rustls.h rustls_connection_set_log_callback"
+  connectionSetLogCallback :: Ptr Connection -> FunPtr LogCallback -> IO ()
+
+foreign import capi unsafe "rustls.h rustls_connection_is_handshaking"
+  connectionIsHandshaking :: Ptr Connection -> IO CBool
+
+foreign import capi unsafe "rustls.h rustls_connection_get_alpn_protocol"
+  connectionGetALPNProtocol :: Ptr Connection -> Ptr (Ptr Word8) -> Ptr CSize -> IO ()
+
+foreign import capi unsafe "rustls.h rustls_connection_get_protocol_version"
+  connectionGetProtocolVersion :: Ptr Connection -> IO TLSVersion
+
+foreign import capi unsafe "rustls.h rustls_connection_get_negotiated_ciphersuite"
+  connectionGetNegotiatedCipherSuite :: Ptr Connection -> IO (Ptr SupportedCipherSuite)
+
+foreign import capi unsafe "rustls.h rustls_server_connection_get_sni_hostname"
+  serverConnectionGetSNIHostname :: Ptr Connection -> Ptr Word8 -> CSize -> Ptr CSize -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_connection_get_peer_certificate"
+  connectionGetPeerCertificate :: Ptr Connection -> CSize -> IO (Ptr Certificate)
+
+-- connection read/write
+
+type ReadWriteCallback = Ptr Userdata -> Ptr Word8 -> CSize -> Ptr CSize -> IO IOResult
+
+foreign import ccall "wrapper"
+  mkReadWriteCallback :: ReadWriteCallback -> IO (FunPtr ReadWriteCallback)
+
+-- connection read
+
+foreign import capi "rustls.h rustls_connection_read_tls"
+  connectionReadTls ::
+    Ptr Connection -> FunPtr ReadWriteCallback -> Ptr Userdata -> Ptr CSize -> IO IOResult
+
+foreign import capi "rustls.h rustls_connection_read"
+  connectionRead :: Ptr Connection -> Ptr Word8 -> CSize -> Ptr CSize -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_connection_wants_read"
+  connectionWantsRead :: Ptr Connection -> IO CBool
+
+-- connection write
+
+foreign import capi "rustls.h rustls_connection_write_tls"
+  connectionWriteTls ::
+    Ptr Connection -> FunPtr ReadWriteCallback -> Ptr Userdata -> Ptr CSize -> IO IOResult
+
+foreign import capi "rustls.h rustls_connection_write"
+  connectionWrite :: Ptr Connection -> Ptr Word8 -> CSize -> Ptr CSize -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_connection_wants_write"
+  connectionWantsWrite :: Ptr Connection -> IO CBool
+
+-- misc
+
+foreign import capi "rustls.h rustls_connection_process_new_packets"
+  connectionProcessNewPackets :: Ptr Connection -> IO Result
+
+foreign import capi "rustls.h rustls_connection_send_close_notify"
+  connectionSendCloseNotify :: Ptr Connection -> IO ()
+
+-- TODO high level bindings?
+foreign import capi unsafe "rustls.h rustls_connection_set_buffer_limit"
+  connectionSetBufferLimit :: Ptr Connection -> CSize -> IO ()
+
+data {-# CTYPE "rustls.h" "rustls_certified_key" #-} CertifiedKey
+
+foreign import capi unsafe "rustls.h rustls_certified_key_build"
+  certifiedKeyBuild :: Ptr Word8 -> CSize -> Ptr Word8 -> CSize -> Ptr (Ptr CertifiedKey) -> IO Result
+
+foreign import capi unsafe "rustls.h rustls_certified_key_free"
+  certifiedKeyFree :: Ptr CertifiedKey -> IO ()
+
+data {-# CTYPE "rustls.h" "rustls_certificate" #-} Certificate
+
+foreign import capi unsafe "rustls.h rustls_certificate_get_der"
+  certificateGetDER :: Ptr Certificate -> Ptr (Ptr Word8) -> Ptr CSize -> IO Result
+
+-- TLS params
+
+data {-# CTYPE "rustls.h" "rustls_supported_ciphersuite" #-} SupportedCipherSuite
+
+foreign import capi "rustls.h value RUSTLS_ALL_CIPHER_SUITES"
+  allCipherSuites :: Ptr (Ptr SupportedCipherSuite)
+
+foreign import capi "rustls.h value RUSTLS_ALL_CIPHER_SUITES_LEN"
+  allCipherSuitesLen :: CSize
+
+foreign import capi "rustls.h value RUSTLS_DEFAULT_CIPHER_SUITES"
+  defaultCipherSuites :: Ptr (Ptr SupportedCipherSuite)
+
+foreign import capi "rustls.h value RUSTLS_DEFAULT_CIPHER_SUITES_LEN"
+  defaultCipherSuitesLen :: CSize
+
+foreign import capi unsafe "rustls.h rustls_supported_ciphersuite_get_suite"
+  supportedCipherSuiteGetSuite :: Ptr SupportedCipherSuite -> Word16
+
+foreign import capi unsafe "hs_rustls.h hs_rustls_supported_ciphersuite_get_name"
+  hsSupportedCipherSuiteGetName :: Ptr SupportedCipherSuite -> Ptr Str -> IO ()
+
+-- | A TLS protocol version supported by Rustls.
+newtype {-# CTYPE "stdint.h" "uint16_t" #-} TLSVersion = TLSVersion
+  { unTLSVersion :: Word16
+  }
+  deriving stock (Show, Eq, Ord)
+  deriving newtype (Storable)
+
+pattern TLS12, TLS13 :: TLSVersion
+pattern TLS12 = TLSVersion 0x0303
+pattern TLS13 = TLSVersion 0x0304
+
+foreign import capi "rustls.h value RUSTLS_ALL_VERSIONS"
+  allVersions :: Ptr TLSVersion
+
+foreign import capi "rustls.h value RUSTLS_ALL_VERSIONS_LEN"
+  allVersionsLen :: CSize
+
+foreign import capi "rustls.h value RUSTLS_DEFAULT_VERSIONS"
+  defaultVersions :: Ptr TLSVersion
+
+foreign import capi "rustls.h value RUSTLS_DEFAULT_VERSIONS_LEN"
+  defaultVersionsLen :: CSize
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,312 @@
+module Main where
+
+import Control.Applicative (liftA2)
+import Control.Concurrent.Async (concurrently)
+import Control.Concurrent.STM.TMVar
+import qualified Control.Exception as E
+import Control.Monad (join, unless, when)
+import Control.Monad.IO.Class
+import Control.Monad.STM (atomically)
+import Control.Monad.Trans.Except
+import Control.Monad.Trans.State.Strict (execStateT, modify')
+import Data.Acquire
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as B
+import Data.Foldable (for_)
+import Data.Functor (void)
+import Data.IORef
+import qualified Data.List.NonEmpty as NE
+import Data.Maybe (fromMaybe, isJust)
+import qualified Data.Set as S
+import Data.Text (Text)
+import qualified Data.Text as T
+import Hedgehog
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+import qualified Rustls
+import qualified System.Directory as Dir
+import System.FilePath ((</>))
+import qualified System.IO.Temp as Temp
+import qualified System.Process as Process
+import Test.Tasty
+import Test.Tasty.HUnit hiding (assert)
+import Test.Tasty.Hedgehog
+
+data TestSetup = TestSetup
+  { clientConfigBuilder :: Rustls.ClientConfigBuilder,
+    serverConfigBuilder :: Rustls.ServerConfigBuilder,
+    clientSends :: [ByteString]
+  }
+  deriving stock (Show)
+
+data MiniCA = MiniCA
+  { miniCAFile :: FilePath,
+    miniCACert :: ByteString,
+    miniCAClientCertKey, miniCAServerCertKey :: Rustls.CertifiedKey
+  }
+
+genTestSetup :: MonadGen m => MiniCA -> m TestSetup
+genTestSetup MiniCA {..} = do
+  commonALPNProtocols <- genALPNProtocols
+  clientConfigRoots <-
+    Gen.element
+      [ Rustls.ClientRootsFromFile miniCAFile,
+        Rustls.ClientRootsInMemory [Rustls.PEMCertificatesStrict miniCACert],
+        Rustls.ClientRootsInMemory [Rustls.PEMCertificatesLax miniCACert]
+      ]
+  clientConfigALPNProtocols <- (commonALPNProtocols <>) <$> genALPNProtocols
+  clientConfigEnableSNI <- Gen.bool_
+  clientConfigTLSVersions <- genTLSVersions
+  clientConfigCertifiedKeys <- Gen.subsequence [miniCAClientCertKey]
+  let clientConfigCipherSuites = getCipherSuites clientConfigTLSVersions
+      clientConfigBuilder = Rustls.ClientConfigBuilder {..}
+  serverConfigALPNProtocols <- (commonALPNProtocols <>) <$> genALPNProtocols
+  serverConfigIgnoreClientOrder <- Gen.bool_
+  serverConfigTLSVersions <- genTLSVersions
+  serverConfigClientCertVerifier <-
+    Gen.element
+      [ Nothing,
+        Just $ Rustls.ClientCertVerifier [Rustls.PEMCertificatesStrict miniCACert],
+        Just $ Rustls.ClientCertVerifierOptional [Rustls.PEMCertificatesStrict miniCACert]
+      ]
+  let serverConfigCipherSuites = getCipherSuites serverConfigTLSVersions
+      serverConfigCertifiedKeys = pure miniCAServerCertKey
+      serverConfigBuilder = Rustls.ServerConfigBuilder {..}
+  clientSends <-
+    Gen.list (Range.linear 0 10) $
+      Gen.filterT (/= "close") $
+        Gen.bytes (Range.linear 1 50)
+  pure TestSetup {..}
+  where
+    genALPNProtocols =
+      Gen.list (Range.constant 0 10) $
+        Rustls.ALPNProtocol <$> Gen.bytes (Range.constant 1 10)
+    genTLSVersions =
+      Gen.shuffle =<< Gen.subsequence (NE.toList Rustls.allTLSVersions)
+    getCipherSuites tlsVersions =
+      filter ((`elem` tlsVersions) . tlsVersionFromCipherSuite) $
+        NE.toList Rustls.allCipherSuites
+
+data TestOutcome = TestOutcome
+  { negotiatedClientALPNProtocol, negotiatedServerALPNProtocol :: Maybe Rustls.ALPNProtocol,
+    clientTLSVersion, serverTLSVersion :: Rustls.TLSVersion,
+    clientCipherSuite, serverCipherSuite :: Rustls.CipherSuite,
+    sniHostname :: Maybe Text,
+    clientPeerCert, serverPeerCert :: Maybe Rustls.DERCertificate,
+    clientReceived, serverReceived :: [ByteString]
+  }
+
+main :: IO ()
+main =
+  defaultMain . testGroup "Basic Rustls tests" $
+    [ testCase "Test version" $ Rustls.version @?= "rustls-ffi/0.9.1/rustls/0.20.4",
+      testCase "TLS versions" do
+        S.fromList [Rustls.TLS12, Rustls.TLS13]
+          @?= S.fromList (NE.toList Rustls.defaultTLSVersions)
+        assertBool "Unexpected default TLS versions" $
+          S.fromList (NE.toList Rustls.defaultTLSVersions)
+            `S.isSubsetOf` S.fromList (NE.toList Rustls.allTLSVersions),
+      testCase "Cipher suites" do
+        let defaultCipherSuites = S.fromList (NE.toList Rustls.defaultCipherSuites)
+            allCipherSuites = S.fromList (NE.toList Rustls.allCipherSuites)
+        assertBool "Unexpected default cipher suites" $
+          defaultCipherSuites `S.isSubsetOf` allCipherSuites
+        assertBool "Misbehaving ID function for cipher suites" $
+          S.map Rustls.cipherSuiteID defaultCipherSuites
+            `S.isSubsetOf` S.map Rustls.cipherSuiteID allCipherSuites
+        assertBool "Misbehaving display function for cipher suites" $
+          S.map Rustls.showCipherSuite defaultCipherSuites
+            `S.isSubsetOf` S.map Rustls.showCipherSuite allCipherSuites,
+      testInMemory
+    ]
+  where
+    testHostname = "example.org"
+    testMessageLen = 1000
+    testInMemory = withMiniCA \(fmap snd -> getMiniCA) ->
+      testPropertyNamed "Test in-memory TLS" "Test in-memory TLS" $ property do
+        TestSetup {..} <- forAll . genTestSetup =<< liftIO getMiniCA
+
+        logRef <- liftIO $ newIORef []
+
+        let runServer backend = withAcquire
+              do
+                lc <- mkTestLogCallback logRef "SERVER"
+                rustlsConfig <-
+                  liftIO . fmap (\cfg -> cfg {Rustls.serverConfigLogCallback = Just lc}) $
+                    Rustls.buildServerConfig serverConfigBuilder
+                Rustls.newServerConnection backend rustlsConfig
+              \conn -> do
+                (alpnProtocol, tlsVersion, cipherSuite, sniHostname, peerCert) <-
+                  Rustls.handshake conn $
+                    (,,,,)
+                      <$> Rustls.getALPNProtocol
+                      <*> Rustls.getTLSVersion
+                      <*> Rustls.getCipherSuite
+                      <*> Rustls.getSNIHostname
+                      <*> Rustls.getPeerCertificate 0
+                received <-
+                  let go = do
+                        bs <- Rustls.readBS conn testMessageLen
+                        when (bs /= "close") do
+                          modify' (bs :)
+                          Rustls.writeBS conn bs
+                          go
+                   in recordOutput go
+                pure (alpnProtocol, tlsVersion, cipherSuite, sniHostname, peerCert, received)
+
+            runClient backend = withAcquire
+              do
+                lc <- mkTestLogCallback logRef "CLIENT"
+                rustlsConfig <-
+                  liftIO . fmap (\cfg -> cfg {Rustls.clientConfigLogCallback = Just lc}) $
+                    Rustls.buildClientConfig clientConfigBuilder
+                Rustls.newClientConnection backend rustlsConfig testHostname
+              \conn -> do
+                (alpnProtocol, tlsVersion, cipherSuite, peerCert) <-
+                  Rustls.handshake conn $
+                    (,,,)
+                      <$> Rustls.getALPNProtocol
+                      <*> Rustls.getTLSVersion
+                      <*> Rustls.getCipherSuite
+                      <*> Rustls.getPeerCertificate 0
+                received <- recordOutput . for_ clientSends $ \bs -> do
+                  Rustls.writeBS conn bs
+                  bs <- Rustls.readBS conn testMessageLen
+                  modify' (bs :)
+                Rustls.writeBS conn "close"
+                pure (alpnProtocol, tlsVersion, cipherSuite, peerCert, received)
+
+        (backend0, backend1) <- mkConnectedBSBackends
+
+        res <- liftIO . runExceptT $ do
+          ( ( negotiatedServerALPNProtocol,
+              serverTLSVersion,
+              serverCipherSuite,
+              sniHostname,
+              serverPeerCert,
+              serverReceived
+              ),
+            ( negotiatedClientALPNProtocol,
+              clientTLSVersion,
+              clientCipherSuite,
+              clientPeerCert,
+              clientReceived
+              )
+            ) <-
+            ExceptT . E.try $ concurrently (runServer backend0) (runClient backend1)
+          pure TestOutcome {..}
+
+        do
+          logLines <- liftIO $ T.unlines . reverse <$> readIORef logRef
+          footnote $ "TLS log:\n" <> T.unpack logLines
+
+        let Rustls.ClientConfigBuilder {..} = clientConfigBuilder
+            Rustls.ServerConfigBuilder {..} = serverConfigBuilder
+            clientTLSVersions =
+              nonEmptySet Rustls.defaultTLSVersions clientConfigTLSVersions
+            serverTLSVersions =
+              nonEmptySet Rustls.defaultTLSVersions serverConfigTLSVersions
+            clientCipherSuites =
+              nonEmptySet Rustls.defaultCipherSuites clientConfigCipherSuites
+            serverCipherSuites =
+              nonEmptySet Rustls.defaultCipherSuites serverConfigCipherSuites
+        case res of
+          Right TestOutcome {..} -> do
+            label "Success"
+            clientSends === serverReceived
+            clientSends === clientReceived
+            if clientConfigEnableSNI
+              then sniHostname === Just testHostname
+              else sniHostname === Nothing
+            assert $
+              S.fromList [clientTLSVersion, serverTLSVersion]
+                `S.isSubsetOf` S.fromList [Rustls.TLS12, Rustls.TLS13]
+            negotiatedClientALPNProtocol === negotiatedServerALPNProtocol
+            assert $
+              maybe S.empty S.singleton negotiatedClientALPNProtocol
+                `S.isSubsetOf` ( S.fromList clientConfigALPNProtocols
+                                   `S.intersection` S.fromList serverConfigALPNProtocols
+                               )
+            clientCipherSuite === serverCipherSuite
+            assert $
+              clientCipherSuite
+                `S.member` (clientCipherSuites `S.intersection` serverCipherSuites)
+            assert $ isJust clientPeerCert
+            case serverConfigClientCertVerifier of
+              Nothing ->
+                serverPeerCert === Nothing
+              Just (Rustls.ClientCertVerifier _) ->
+                assert $ isJust serverPeerCert
+              Just (Rustls.ClientCertVerifierOptional _) ->
+                isJust serverPeerCert /== null clientConfigCertifiedKeys
+          Left (ex :: Rustls.RustlsException) -> do
+            label "Expected TLS failure"
+            annotate $ E.displayException ex
+            if
+                | S.fromList clientConfigALPNProtocols
+                    `S.disjoint` S.fromList serverConfigALPNProtocols ->
+                    success
+                | clientTLSVersions `S.disjoint` serverTLSVersions ->
+                    success
+                | Just (Rustls.ClientCertVerifier _) <- serverConfigClientCertVerifier,
+                  null clientConfigCertifiedKeys ->
+                    success
+                | otherwise -> failure
+      where
+        recordOutput = fmap reverse . flip execStateT []
+
+        nonEmptySet def = S.fromList . NE.toList . fromMaybe def . NE.nonEmpty
+
+        withMiniCA = withResource
+          do
+            tmpDir <-
+              flip Temp.createTempDirectory "hs-rustls-minica"
+                =<< Temp.getCanonicalTemporaryDirectory
+            let runMiniCA domain =
+                  void $ Process.readCreateProcess (cp {Process.cwd = Just tmpDir}) ""
+                  where
+                    cp = Process.proc "minica" ["-domains", domain]
+            for_ ["example.org", "client.example.org"] runMiniCA
+            let miniCAFile = tmpDir </> "minica.pem"
+            miniCACert <- B.readFile miniCAFile
+            let miniCACertKey domain = do
+                  privateKey <- B.readFile $ tmpDir </> domain </> "key.pem"
+                  certificateChain <- B.readFile $ tmpDir </> domain </> "cert.pem"
+                  pure Rustls.CertifiedKey {..}
+            miniCAClientCertKey <- miniCACertKey "client.example.org"
+            miniCAServerCertKey <- miniCACertKey "example.org"
+            pure (tmpDir, MiniCA {..})
+          do \(tmpDir, _) -> Dir.removeDirectoryRecursive tmpDir
+
+mkConnectedBSBackends :: MonadIO m => m (Rustls.ByteStringBackend, Rustls.ByteStringBackend)
+mkConnectedBSBackends = liftIO do
+  (buf0, buf1) <- join (liftA2 (,)) newEmptyTMVarIO
+  pure (mkBSBackend buf0 buf1, mkBSBackend buf1 buf0)
+  where
+    mkBSBackend readBuf writeBuf = Rustls.ByteStringBackend {..}
+      where
+        bsbRead len = atomically do
+          (bs, bs') <- B.splitAt len <$> takeTMVar readBuf
+          unless (B.null bs') $ putTMVar readBuf bs'
+          pure bs
+        bsbWrite bs =
+          atomically $ putTMVar writeBuf bs
+
+mkTestLogCallback :: IORef [Text] -> Text -> Acquire Rustls.LogCallback
+mkTestLogCallback ref id = Rustls.newLogCallback \lvl msg -> do
+  let lvlTxt = case lvl of
+        Rustls.LogLevelError -> "ERROR"
+        Rustls.LogLevelWarn -> "WARN"
+        Rustls.LogLevelInfo -> "INFO"
+        Rustls.LogLevelDebug -> "DEBUG"
+        Rustls.LogLevelTrace -> "TRACE"
+      line = "[" <> id <> "] [" <> lvlTxt <> "] " <> msg
+  atomicModifyIORef' ref ((,()) . (line :))
+
+tlsVersionFromCipherSuite :: Rustls.CipherSuite -> Rustls.TLSVersion
+tlsVersionFromCipherSuite cipherSuite
+  | "TLS_" `T.isPrefixOf` str = Rustls.TLS12
+  | "TLS13_" `T.isPrefixOf` str = Rustls.TLS13
+  | otherwise = error "unexpected cipher suite"
+  where
+    str = Rustls.showCipherSuite cipherSuite
