diff --git a/ChangeLog.md b/ChangeLog.md
index a9eb21b10669bc1ad8681521111012ba23c40e9d..c0c0bd621c150e9d111c3099bdf67bb5c3b242cf 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,9 @@
 # Changelog for tahoe-great-black-swamp
 
+## X.Y.Z
+
+* ``TahoeLAFS.Storage.Client.runGBS`` is a new high-level API for performing an interaction with a GBS server.
+
 ## 0.3.0.1
 
 * Package metadata improvements.
diff --git a/README.rst b/README.rst
index d6225c39c30928e2fafb8ac781d4a76653a871d3..0a126dbb6c8dce31f47a2c5ba4a12e450cbe34f9 100644
--- a/README.rst
+++ b/README.rst
@@ -21,6 +21,31 @@ Run Unit Tests
 
    stack test
 
+Write a Client
+--------------
+
+Here's a client program that shows two things:
+
+* a server's version response
+* all of the share numbers it claims to hold for a particular storage index
+
+The server details are encoded in the NURL and the storage index is hard-coded in another string.
+
+::
+
+   import Control.Monad.IO.Class (liftIO)
+   import TahoeLAFS.Storage.Client (runGBS, version, getImmutableShareNumbers, parseNURL)
+
+   main :: IO ()
+   main =
+     let
+       Just nURL = parseNURL "pb://..."
+       storageIndex = "aaabbbcccdddeeefffggg"
+     in
+       runGBS nURL $ do
+       version >>= liftIO . print
+       getImmutableShareNumbers storageIndex >>= liftIO . print
+
 Generate GBS Clients
 --------------------
 
diff --git a/Setup.hs b/Setup.hs
index 9a994af677b0dfd41b4e3b76b3e7e604003d64e1..e8ef27dbba9992f80d9271a60892aadc63c9ef36 100644
--- a/Setup.hs
+++ b/Setup.hs
@@ -1,2 +1,3 @@
 import Distribution.Simple
+
 main = defaultMain
diff --git a/client-test/Main.hs b/client-test/Main.hs
index 643afa4b29284634ded287a12359238d950fca64..dff9a2712ed3c84af8ea54c6e0d59a90bdcde338 100644
--- a/client-test/Main.hs
+++ b/client-test/Main.hs
@@ -1,5 +1,4 @@
 {-# LANGUAGE DataKinds #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecordWildCards #-}
 
 {- | Demonstrate the use of some GBS client APIs.
@@ -11,112 +10,46 @@
 module Main where
 
 import Data.ByteString.Base32 (encodeBase32Unpadded)
-
-import qualified Data.ByteString.Base64 as Base64
-
-import Data.Text
-import Data.Text.Encoding (encodeUtf8)
-
-import Network.Connection (TLSSettings (TLSSettingsSimple))
-import Network.HTTP.Client (
-    ManagerSettings (managerModifyRequest),
-    Request (requestHeaders),
- )
-import Network.HTTP.Client.TLS (
-    mkManagerSettings,
-    newTlsManagerWith,
- )
-import Network.HTTP.Types ()
-import Network.URI (
-    URI (URI, uriAuthority, uriPath),
-    URIAuth (URIAuth, uriPort, uriRegName),
-    parseURI,
- )
-import Servant.Client (
-    BaseUrl (BaseUrl),
-    ClientError,
-    ClientM,
-    Scheme (Https),
-    mkClientEnv,
-    runClientM,
- )
+import qualified Data.Text as T
 import System.Environment (getArgs)
 import Tahoe.CHK.Capability (
     CHK (CHKReader),
     Reader (Reader, verifier),
-    Verifier (
-        Verifier,
-        fingerprint,
-        required,
-        size,
-        storageIndex,
-        total
-    ),
+    Verifier (Verifier, storageIndex),
     pCapability,
  )
-import TahoeLAFS.Storage.Client
-import Text.Megaparsec
-
-import TahoeLAFS.Storage.API
+import TahoeLAFS.Storage.API (ShareNumber (..))
+import TahoeLAFS.Storage.Client (
+    getImmutableShareNumbers,
+    parseNURL,
+    readImmutableShare,
+    runGBS,
+    version,
+ )
+import Text.Megaparsec (parse)
 
 main :: IO ()
 main = do
-    [storageFURLStr, capStr, shareNumStr] <- getArgs
-    let Right (CHKReader Reader{verifier = Verifier{..}}) = parse pCapability "argv[2]" (Data.Text.pack capStr)
-        Just URI{uriAuthority = Just URIAuth{uriRegName = hostname, uriPort = (':' : port)}, uriPath = ('/' : swissnum)} = parseFURL storageFURLStr
-
-    run (Data.Text.unpack . Data.Text.toLower . encodeBase32Unpadded $ storageIndex) hostname (read port) swissnum (ShareNumber (read shareNumStr))
-
--- Parse it like a regular URI after removing the confusing "tcp:" prefix on
--- the netloc.
-parseFURL :: String -> Maybe URI
-parseFURL = parseURI . Data.Text.unpack . Data.Text.replace "tcp:" "" . Data.Text.pack
-
--- Add the necessary authorization header.
-fixAccept :: Applicative f => String -> Request -> f Request
-fixAccept swissnum req =
-    pure req{requestHeaders = ("Authorization", "Tahoe-LAFS " <> enc swissnum) : requestHeaders req}
-  where
-    enc = Base64.encode . encodeUtf8 . Data.Text.pack
-
-fixAcceptPrint :: String -> Request -> IO Request
-fixAcceptPrint swissnum req = do
-    print req
-    fixAccept swissnum req
-
--- Do some API calls and report the results.
-run ::
-    -- | The base32-encoded storage index for which to request share info.
-    String ->
-    -- | The hostname or IP address of the storage server to query.
-    String ->
-    -- | The port number of the storage server to query.
-    Int ->
-    -- | The swissnum of the storage service
-    String ->
-    -- | A share number to download from the server.
-    ShareNumber ->
-    IO ()
-run storageIndex hostname port swissnum shareNum = do
-    manager' <- newTlsManagerWith managerSettings
-    let callIt :: ClientM a -> IO (Either ClientError a)
-        callIt = flip runClientM (mkClientEnv manager' (BaseUrl Https hostname port ""))
-
-    putStrLn "getVersion"
-    ver <- callIt version
-    showIt ver
-    putStrLn "getImmutableShareNumbers:"
-    sharez <- callIt $ getImmutableShareNumbers storageIndex
-    showIt sharez
-    putStrLn "readImmutableShare - succeeds!"
-    chk <- callIt $ readImmutableShare storageIndex shareNum Nothing
-    showIt chk
-  where
-    tlsSettings = TLSSettingsSimple True True True
-    sockSettings = Nothing
-    managerSettings = (mkManagerSettings tlsSettings sockSettings){managerModifyRequest = fixAccept swissnum}
-
-showIt :: (Show a1, Show a2) => Either a1 a2 -> IO ()
-showIt what = case what of
-    Left err -> putStrLn $ "Error: " <> show err
-    Right it -> print it
+    [storageNURLStr, capStr, shareNumStr] <- getArgs
+    let Right (CHKReader Reader{verifier = Verifier{..}}) = parse pCapability "argv[2]" (T.pack capStr)
+        nurlM = parseNURL . T.pack $ storageNURLStr
+
+    case nurlM of
+        Nothing ->
+            print ("Failed to parse NURL" :: T.Text)
+        Just nurl -> do
+            result <- runGBS nurl $ do
+                ver <- version
+                sharez <- getImmutableShareNumbers storageIndexS
+                chk <- readImmutableShare storageIndexS shareNum Nothing
+                pure (ver, sharez, chk)
+
+            case result of
+                Left err -> print $ "Request error:  " <> show err
+                Right (ver, sharez, chk) -> do
+                    print $ "version: " <> show ver
+                    print $ "share numbers: " <> show sharez
+                    print $ "share bytes: " <> show chk
+          where
+            storageIndexS = T.unpack . T.toLower . encodeBase32Unpadded $ storageIndex
+            shareNum = ShareNumber $ read shareNumStr
diff --git a/src/TahoeLAFS/Internal/Client.hs b/src/TahoeLAFS/Internal/Client.hs
new file mode 100644
index 0000000000000000000000000000000000000000..a6ab6292ccd81713b5b9e26f67c730e13d35723f
--- /dev/null
+++ b/src/TahoeLAFS/Internal/Client.hs
@@ -0,0 +1,171 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PackageImports #-}
+
+{- | Implement the correct HTTPS client configuration for using Great Black
+ Swamp.  This is necessary and correct for authenticating Great Black
+ Swamp's self-authenticating URLs.
+-}
+module TahoeLAFS.Internal.Client where
+
+import qualified "base64-bytestring" Data.ByteString.Base64 as Base64
+
+import Crypto.Hash (Digest, hash)
+import Crypto.Hash.Algorithms (SHA256)
+import Data.ASN1.BinaryEncoding (DER (DER))
+import Data.ASN1.Encoding (encodeASN1')
+import Data.ASN1.Types (ASN1Object (toASN1))
+import Data.ByteArray (convert)
+import qualified Data.ByteString as B
+import Data.Default.Class (Default (def))
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import Data.X509 (
+    Certificate (certPubKey),
+    CertificateChain (CertificateChain),
+    PubKey,
+    Signed (signedObject),
+    SignedExact (getSigned),
+ )
+import Data.X509.CertificateStore (CertificateStore)
+import Data.X509.Validation (
+    FailedReason (AuthorityTooDeep, EmptyChain, InvalidSignature),
+    ServiceID,
+    SignatureFailure (SignaturePubkeyMismatch),
+    SignatureVerification (SignatureFailed, SignaturePass),
+    verifySignedSignature,
+ )
+import Network.Connection (TLSSettings (..))
+import Network.HTTP.Client (ManagerSettings, Request (requestHeaders), managerModifyRequest)
+import Network.HTTP.Client.TLS (mkManagerSettings)
+import Network.HTTP.Types (Header)
+import Network.TLS (
+    ClientHooks (onServerCertificate),
+    ClientParams (..),
+    Supported (..),
+    ValidationCache,
+ )
+import Network.TLS.Extra.Cipher (ciphersuite_default)
+
+newtype SPKIHash = SPKIHash B.ByteString deriving (Eq, Ord)
+
+instance Show SPKIHash where
+    show (SPKIHash bs) = "SPKIHash " <> T.unpack (T.decodeLatin1 (Base64.encode bs))
+
+{- | Create a ManagerSettings suitable for use with Great Black Swamp client
+ requests.
+-}
+mkGBSManagerSettings ::
+    -- | The SPKI hash of the certificate of the storage service to access.
+    SPKIHash ->
+    -- | The secret capability identifying the storage service to access.
+    T.Text ->
+    -- | The settings.
+    ManagerSettings
+mkGBSManagerSettings requiredHash swissnum =
+    (mkManagerSettings (gbsTLSSettings requiredHash) sockSettings)
+        { managerModifyRequest = addAuthorization swissnum
+        }
+  where
+    sockSettings = Nothing
+
+{- | The TLSSettings suitable for use with Great Black Swamp client requests.
+ These ensure we can authenticate the server before using it.
+-}
+gbsTLSSettings :: SPKIHash -> TLSSettings
+gbsTLSSettings requiredHash =
+    TLSSettings
+        ( ClientParams
+            { clientUseMaxFragmentLength = Nothing
+            , clientServerIdentification = ("", "")
+            , clientUseServerNameIndication = True
+            , clientWantSessionResume = Nothing
+            , clientShared = def
+            , clientHooks =
+                def
+                    { onServerCertificate = validateGBSCertificate requiredHash
+                    }
+            , clientSupported = def{supportedCiphers = ciphersuite_default}
+            , clientDebug = def
+            , clientEarlyData = Nothing
+            }
+        )
+
+{- | Determine the validity of an x509 certificate presented during a TLS
+ handshake for a GBS connection.
+
+ The certificate is considered valid if its signature can be validated and
+ the sha256 hash of its SPKI fields match the expected value.
+
+ If not exactly one certificate is presented then validation fails.
+-}
+validateGBSCertificate :: SPKIHash -> CertificateStore -> ValidationCache -> ServiceID -> CertificateChain -> IO [FailedReason]
+validateGBSCertificate _ _ _ _ (CertificateChain []) = pure [EmptyChain]
+validateGBSCertificate requiredSPKIFingerprint _ _ _ (CertificateChain [signedExactCert]) =
+    -- Nothing is valid unless the signature on the certificate is valid
+    -- so do that first.
+    case verifySignedSignature signedExactCert pubKey of
+        SignatureFailed failure -> pure [InvalidSignature failure]
+        SignaturePass -> do
+            -- The certificates SubjectPublicKeyInfo must match the hash we
+            -- expect, too.
+            if spkiFingerprint cert == requiredSPKIFingerprint
+                then pure []
+                else do
+                    pure [InvalidSignature SignaturePubkeyMismatch]
+  where
+    pubKey = certPubKey cert
+    cert = signedObject . getSigned $ signedExactCert
+validateGBSCertificate _ _ _ _ _ = pure [AuthorityTooDeep]
+
+sha256 :: B.ByteString -> B.ByteString
+sha256 = convert . (hash :: B.ByteString -> Digest SHA256)
+
+{- | Extract the SubjectPublicKeyInfo from a Certificate.
+
+ The PubKey type contains all of the values related to the
+ SubjectPublicKeyInfo and serializes correctly for this type so we just
+ extract that.
+-}
+spki :: Certificate -> PubKey
+spki = certPubKey
+
+{- | Construct the bytes which can be hashed to produce the SPKI Fingerprint
+ for the given Certificate.
+-}
+spkiBytes :: Certificate -> B.ByteString
+spkiBytes = encodeASN1' DER . flip toASN1 [] . spki
+
+-- | Compute the SPKI Fingerprint (RFC 7469) for the given Certificate.
+spkiFingerprint :: Certificate -> SPKIHash
+spkiFingerprint = SPKIHash . sha256 . spkiBytes
+
+-- Add the necessary authorization header.  Since this is used with
+-- `managerModifyRequest`, it may be called more than once per request so it
+-- needs to take care not to double up headers.
+-- https://github.com/snoyberg/http-client/issues/350
+addAuthorization :: Applicative f => T.Text -> Request -> f Request
+addAuthorization swissnum req =
+    pure
+        req
+            { requestHeaders = addHeader authz . requestHeaders $ req
+            }
+  where
+    enc = Base64.encode . T.encodeUtf8
+    authz = ("Authorization", "Tahoe-LAFS " <> enc swissnum)
+
+    addHeader :: Header -> [Header] -> [Header]
+    addHeader (name, value) [] = [(name, value)]
+    addHeader (name, value) (o@(name', value') : xs)
+        | name == name' = o : xs
+        | otherwise = o : addHeader (name, value) xs
+
+addAuthorizationPrint :: T.Text -> Request -> IO Request
+addAuthorizationPrint swissnum req = do
+    print "Before"
+    print req
+    print "--------"
+    r <- addAuthorization swissnum req
+    print "After"
+    print r
+    print "--------"
+    pure r
diff --git a/src/TahoeLAFS/Internal/ServantUtil.hs b/src/TahoeLAFS/Internal/ServantUtil.hs
index baffc0df31cd11816f3f8b151354658ada7fc935..ad1dd955c859efdc7f22d36060bd5d333fb6a35e 100644
--- a/src/TahoeLAFS/Internal/ServantUtil.hs
+++ b/src/TahoeLAFS/Internal/ServantUtil.hs
@@ -1,6 +1,7 @@
 {-# LANGUAGE FlexibleInstances #-}
 {-# LANGUAGE MultiParamTypeClasses #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PackageImports #-}
 {-# OPTIONS_GHC -fno-warn-orphans #-}
 
 module TahoeLAFS.Internal.ServantUtil (
@@ -14,8 +15,7 @@ import Network.HTTP.Media (
 import Data.ByteString (
     ByteString,
  )
-import qualified Data.ByteString.Base64 as Base64
-import qualified Data.Text as T
+import qualified "base64-bytestring" Data.ByteString.Base64 as Base64
 import Data.Text.Encoding (
     decodeLatin1,
     encodeUtf8,
diff --git a/src/TahoeLAFS/Storage/API.hs b/src/TahoeLAFS/Storage/API.hs
index b899f88495973b6f6acea9e8d32f4d8744eb2836..6caa42da142844a40e4cdf1b359c795ada75203c 100644
--- a/src/TahoeLAFS/Storage/API.hs
+++ b/src/TahoeLAFS/Storage/API.hs
@@ -8,6 +8,7 @@
 -- Supports derivations for ShareNumber
 {-# LANGUAGE GeneralizedNewtypeDeriving #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PackageImports #-}
 {-# LANGUAGE TypeOperators #-}
 
 module TahoeLAFS.Storage.API (
@@ -69,7 +70,7 @@ import Data.Aeson.Types (
  )
 import Data.Bifunctor (Bifunctor (bimap))
 import qualified Data.ByteString as B
-import qualified Data.ByteString.Base64 as Base64
+import qualified "base64-bytestring" Data.ByteString.Base64 as Base64
 import qualified Data.Map as Map
 import Data.Map.Strict (
     Map,
diff --git a/src/TahoeLAFS/Storage/Client.hs b/src/TahoeLAFS/Storage/Client.hs
index 004177988675b97f874aca8b3f7d33d12efb2855..d0333011b33717fc96ba78996a127a0520d488f8 100644
--- a/src/TahoeLAFS/Storage/Client.hs
+++ b/src/TahoeLAFS/Storage/Client.hs
@@ -1,4 +1,7 @@
 {-# LANGUAGE DataKinds #-}
+{-# LANGUAGE ExplicitNamespaces #-}
+{-# LANGUAGE PackageImports #-}
+{-# OPTIONS_GHC -Wno-missing-signatures #-}
 
 module TahoeLAFS.Storage.Client (
     -- General server info
@@ -16,16 +19,43 @@ module TahoeLAFS.Storage.Client (
     readMutableShares,
     getMutableShareNumbers,
     adviseCorruptMutableShare,
+    parseNURL,
+    runGBS,
+    NURL (..),
 ) where
 
-import Data.Proxy
-import Servant
+import Control.Monad ((>=>))
+import qualified "base64" Data.ByteString.Base64.URL as Base64URL
+import Data.Proxy (Proxy (..))
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import Network.HTTP.Client.TLS (
+    newTlsManagerWith,
+ )
+import Network.Socket (HostName, PortNumber)
+import Network.URI (
+    -- URI (URI, uriAuthority, uriPath),
+    URIAuth (URIAuth, uriPort, uriRegName, uriUserInfo),
+    parseURI,
+ )
+import Servant (
+    URI (URI, uriAuthority, uriFragment, uriPath),
+    type (:<|>) ((:<|>)),
+ )
 import Servant.Client (
+    BaseUrl (BaseUrl),
+    ClientError,
+    ClientM,
+    Scheme (Https),
     client,
+    mkClientEnv,
+    runClientM,
  )
+import TahoeLAFS.Internal.Client (SPKIHash (SPKIHash), mkGBSManagerSettings)
 import TahoeLAFS.Storage.API (
     StorageAPI,
  )
+import Text.Read (readMaybe)
 
 newApi :: Proxy StorageAPI
 newApi = Proxy
@@ -41,3 +71,42 @@ newApi = Proxy
         :<|> getMutableShareNumbers
         :<|> adviseCorruptMutableShare
     ) = client newApi
+
+-- | Represent a "new" style service URL.
+data NURL = NURLv1
+    { -- | The cryptographic fingerprint of the server hosting the service.
+      nurlv1Fingerprint :: SPKIHash
+    , -- | A hint about the network location of the server hosting the service.
+      nurlv1Address :: (HostName, PortNumber)
+    , -- | The secret identifier for the service within the scope of the server.
+      nurlv1Swissnum :: T.Text
+    }
+    deriving (Ord, Eq, Show)
+
+-- | Parse a Great Black Swamp NURL from text.
+parseNURL :: T.Text -> Maybe NURL
+parseNURL = parseURI . T.unpack >=> uriToNURL
+
+uriToNURL :: URI -> Maybe NURL
+uriToNURL URI{uriAuthority = Just URIAuth{uriUserInfo, uriRegName = hostname, uriPort = (':' : port)}, uriPath = ('/' : swissnum), uriFragment = "#v=1"} =
+    case (requiredHashE, portM) of
+        (Left _, _) -> Nothing
+        (_, Nothing) -> Nothing
+        (Right requiredHash, Just portNum) -> Just NURLv1{nurlv1Fingerprint = requiredHash, nurlv1Address = (hostname, portNum), nurlv1Swissnum = T.pack swissnum}
+  where
+    requiredHashE = fmap SPKIHash . Base64URL.decodeBase64 . T.encodeUtf8 . T.pack . dropLast 1 $ uriUserInfo
+    portM = readMaybe port
+uriToNURL _ = Nothing
+
+{- | Execute some client operations against the Great Black Swamp server at
+ the location indicated by the given NURL.
+-}
+runGBS :: NURL -> ClientM a -> IO (Either ClientError a)
+runGBS NURLv1{nurlv1Fingerprint, nurlv1Address = (hostname, port), nurlv1Swissnum} action = do
+    manager <- newTlsManagerWith (mkGBSManagerSettings nurlv1Fingerprint nurlv1Swissnum)
+    let clientEnv = mkClientEnv manager (BaseUrl Https hostname (fromIntegral port) "")
+    runClientM action clientEnv
+
+dropLast :: Int -> [a] -> [a]
+dropLast n xs =
+    take (length xs - n) xs
diff --git a/tahoe-great-black-swamp.cabal b/tahoe-great-black-swamp.cabal
index e276a2e85b7e1b7528f8d51c4453861b81017497..c30e0336df5980eabe588a603ee63e860896e61e 100644
--- a/tahoe-great-black-swamp.cabal
+++ b/tahoe-great-black-swamp.cabal
@@ -40,7 +40,7 @@ source-repository head
     gitlab@whetstone.private.storage:privatestorage/tahoe-great-black-swamp.git
 
 common executable-opts
-  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  ghc-options:
 
 common common-opts
   default-extensions:
@@ -81,6 +81,8 @@ library
   import:           common-opts
   hs-source-dirs:   src
   exposed-modules:
+    TahoeLAFS.Internal.Client
+    TahoeLAFS.Internal.ServantUtil
     TahoeLAFS.Storage.API
     TahoeLAFS.Storage.APIDocs
     TahoeLAFS.Storage.Backend
@@ -90,24 +92,38 @@ library
     TahoeLAFS.Storage.Client
     TahoeLAFS.Storage.Server
 
-  other-modules:    TahoeLAFS.Internal.ServantUtil
   default-language: Haskell2010
   build-depends:
-    , base               >=4.7      && <5
-    , base64-bytestring  >=1.0.0.3  && <1.3
-    , cborg              >=0.2.4    && <0.3
-    , cborg-json         >=0.2.2    && <0.3
-    , http-api-data      >=0.4.1.1  && <0.7
-    , http-media         >=0.8      && <0.9
-    , scientific         >=0.3.6.2  && <0.4
-    , serialise          >=0.2.3    && <0.3
-    , servant-client     >=0.16.0.1 && <0.21
-    , servant-docs       >=0.11.4   && <0.14
-    , servant-server     >=0.16.2   && <0.21
-    , utf8-string        >=1.0.1.1  && <1.1
-    , wai                >=3.2.2.1  && <3.3
-    , warp               >=3.3.13   && <3.4
-    , warp-tls           >=3.2.12   && <3.5
+    , asn1-encoding       >=0.9.6    && <0.10
+    , asn1-types          >=0.3.4    && <0.4
+    , base                >=4.7      && <5
+    , base64
+    , base64-bytestring   >=1.0.0.3  && <1.3
+    , cborg               >=0.2.4    && <0.3
+    , cborg-json          >=0.2.2    && <0.3
+    , connection          >=0.3.1    && <0.4
+    , cryptonite
+    , data-default-class
+    , http-api-data       >=0.4.1.1  && <0.7
+    , http-client         >=0.6.4.1  && <0.8
+    , http-client-tls     >=0.3.5.3  && <0.4
+    , http-media          >=0.8      && <0.9
+    , memory
+    , network
+    , network-uri         >=2.6.3    && <2.7
+    , scientific          >=0.3.6.2  && <0.4
+    , serialise           >=0.2.3    && <0.3
+    , servant-client      >=0.16.0.1 && <0.21
+    , servant-docs        >=0.11.4   && <0.14
+    , servant-server      >=0.16.2   && <0.21
+    , tls
+    , utf8-string         >=1.0.1.1  && <1.1
+    , wai                 >=3.2.2.1  && <3.3
+    , warp                >=3.3.13   && <3.4
+    , warp-tls            >=3.2.12   && <3.5
+    , x509
+    , x509-store
+    , x509-validation
 
 -- executable gbs-generate-apidocs
 --   hs-source-dirs:      generate-apidocs
@@ -130,7 +146,6 @@ executable client-test
     , aeson                    >=1.4.7    && <2.2
     , base                     >=4.7      && <5
     , base32                   >=0.2.1    && <0.3
-    , base64-bytestring        >=1.0.0.3  && <1.3
     , bytestring               >=0.10.8.2 && <0.11
     , cborg                    >=0.2.4    && <0.3
     , connection               >=0.3.1    && <0.4
@@ -139,7 +154,6 @@ executable client-test
     , http-client-tls          >=0.3.5.3  && <0.4
     , http-types               >=0.12.3   && <0.13
     , megaparsec               >=8.0      && <9.3
-    , network-uri              >=2.6.3    && <2.7
     , serialise                >=0.2.3    && <0.3
     , servant                  >=0.16.2   && <0.21
     , servant-client           >=0.16.0.1 && <0.21
@@ -175,26 +189,41 @@ test-suite http-tests
   main-is:          Main.hs
   other-modules:
     CBORSpec
+    ClientSpec
     HTTPSpec
     Lib
     MiscSpec
     SemanticSpec
     Spec
+    Vectors
 
   default-language: Haskell2010
   ghc-options:      -Wall
   build-depends:
     , base                     >=4.7      && <5
     , base32string             >=0.9.1    && <0.10
+    , base64
     , cborg                    >=0.2.4    && <0.3
+    , connection
+    , data-default-class
     , hspec                    <2.12
     , hspec-expectations       <0.9
     , hspec-wai                <0.12
+    , http-client              >=0.6.4.1  && <0.8
+    , network
+    , network-simple-tls
     , QuickCheck               <2.15
     , quickcheck-instances     <0.4
     , serialise                >=0.2.3    && <0.3
     , servant                  >=0.16.2   && <0.21
+    , servant-client           >=0.16.0.1 && <0.21
     , tahoe-great-black-swamp
     , temporary                >=1.3      && <1.4
+    , tls
     , vector                   >=0.12.1.2 && <0.13
     , wai-extra                >=3.0.29.2 && <3.2
+    , warp
+    , warp-tls
+    , x509
+    , x509-store
+    , yaml                     >=0.11.5.0 && <0.11.9.0 || >=0.11.9.0.0 && <0.12
diff --git a/test/ClientSpec.hs b/test/ClientSpec.hs
new file mode 100644
index 0000000000000000000000000000000000000000..52baf8c2cf7b23cfaeae56cbf0bc6277fafe7ee5
--- /dev/null
+++ b/test/ClientSpec.hs
@@ -0,0 +1,241 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module ClientSpec where
+
+import qualified Control.Concurrent.Async as Async
+import Control.Exception (Exception, SomeException, throwIO, try)
+import Control.Monad (forM_)
+import Data.Bifunctor (Bifunctor (bimap))
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Lazy as LB
+import Data.Default.Class (Default (def))
+import Data.Either (isRight)
+import qualified Data.Text as T
+import Data.X509 (CertificateChain (..), getSigned, signedObject)
+import GHC.IO (unsafePerformIO)
+import Network.HTTP.Client (
+    defaultRequest,
+    managerModifyRequest,
+    newManager,
+    parseRequest,
+    requestHeaders,
+    withConnection,
+ )
+import Network.HTTP.Client.Internal (Connection (connectionRead))
+import qualified Network.Simple.TCP.TLS as SimpleTLS
+import Network.Socket (
+    AddrInfoFlag (AI_NUMERICHOST, AI_NUMERICSERV),
+    Family (AF_INET),
+    SockAddr (SockAddrInet, SockAddrInet6, SockAddrUnix),
+    Socket,
+    SocketType (Stream),
+    addrAddress,
+    addrFlags,
+    addrSocketType,
+    bind,
+    close',
+    defaultHints,
+    getAddrInfo,
+    getSocketName,
+    hostAddress6ToTuple,
+    hostAddressToTuple,
+    listen,
+    openSocket,
+    socket,
+ )
+import qualified Network.TLS as TLS
+import Network.TLS.Extra.Cipher (ciphersuite_default)
+import Network.Wai.Handler.Warp (defaultSettings)
+import Network.Wai.Handler.WarpTLS (
+    runTLSSocket,
+    tlsSettings,
+ )
+import Servant.Client (ClientError (ConnectionError))
+import TahoeLAFS.Internal.Client (
+    SPKIHash (SPKIHash),
+    mkGBSManagerSettings,
+    spkiBytes,
+    spkiFingerprint,
+ )
+import TahoeLAFS.Storage.Backend.Memory (memoryBackend)
+import TahoeLAFS.Storage.Client (NURL (NURLv1, nurlv1Address), runGBS, version)
+import qualified TahoeLAFS.Storage.Server as Server
+import Test.Hspec (
+    Spec,
+    describe,
+    expectationFailure,
+    it,
+    runIO,
+    shouldBe,
+    shouldContain,
+    shouldReturn,
+    shouldSatisfy,
+    shouldThrow,
+ )
+import Text.Printf (printf)
+import Vectors (SPKICase (..), loadSPKITestVector)
+
+-- Paths to pre-generated test data - an RSA private key and associated
+-- self-signed certificate.
+privateKeyPath :: FilePath
+privateKeyPath = "test/data/private-key.pem"
+certificatePath :: FilePath
+certificatePath = "test/data/certificate.pem"
+
+spkiTestVectorPath :: FilePath
+spkiTestVectorPath = "test/data/spki-hash-test-vectors.yaml"
+
+credentialE :: Either String TLS.Credential
+{-# NOINLINE credentialE #-}
+credentialE = unsafePerformIO $ TLS.credentialLoadX509 certificatePath privateKeyPath
+
+credential :: TLS.Credential
+credential = either (error . ("Failed to load test credentials: " <>) . show) id credentialE
+
+spec :: Spec
+spec = do
+    describe "mkGBSManagerSettings" $ do
+        describe "Authorization header" $ do
+            it "includes the Tahoe-LAFS realm and encoded swissnum" $ do
+                modified <- managerModifyRequest settings request
+                requestHeaders modified
+                    -- Should contain the base64 encoding of the swissnum
+                    `shouldContain` [("authorization", "Tahoe-LAFS c3dpc3NudW0=")]
+            it "does not duplicate the header" $ do
+                modified <- managerModifyRequest settings request
+                modified' <- managerModifyRequest settings modified
+                let authorizations = filter (("authorization" ==) . fst) (requestHeaders modified')
+                length authorizations `shouldBe` 1
+
+        describe "SPKI Fingerprints" $ do
+            vectorE <- runIO $ loadSPKITestVector <$> B.readFile spkiTestVectorPath
+            case vectorE of
+                Left loadErr ->
+                    it "test suite bug" $ expectationFailure $ "could not load test vectors: " <> show loadErr
+                Right vector -> do
+                    describe "spkiBytes" $ do
+                        it "agrees with the test vectors" $ do
+                            forM_ vector $ \(SPKICase{spkiExpected, spkiCertificate}) -> do
+                                spkiBytes spkiCertificate `shouldBe` spkiExpected
+
+                    describe "spkiFingerprint" $ do
+                        it "agrees with the test vectors" $ do
+                            forM_ vector $ \(SPKICase{spkiExpectedHash, spkiCertificate}) -> do
+                                spkiFingerprint spkiCertificate `shouldBe` spkiExpectedHash
+
+        describe "TLS connections" $ do
+            let CertificateChain [signedExactCert] = fst credential
+                requiredHash = spkiFingerprint . signedObject . getSigned $ signedExactCert
+            it "makes a connection to a server using the correct certificate" $ do
+                withTlsServer (TLS.Credentials [credential]) "Hello!" expectServerSuccess $ \serverAddr -> do
+                    let (host, port) = addrToHostPort serverAddr
+                    manager <- newManager (mkGBSManagerSettings requiredHash "swissnum")
+                    req <- parseRequest $ printf "https://%s:%d/" host port
+                    withConnection req manager $ \clientConn -> do
+                        connectionRead clientConn `shouldReturn` "Hello!"
+
+            it "refuses to make a connection to a server not using the correct certificate" $ do
+                withTlsServer (TLS.Credentials [credential]) "Hello!" expectServerFailure $ \serverAddr -> do
+                    let (host, port) = addrToHostPort serverAddr
+                    manager <- newManager (mkGBSManagerSettings (SPKIHash "wrong spki hash") "swissnum")
+                    req <- parseRequest $ printf "https://%s:%d/" host port
+                    withConnection req manager connectionRead
+                        `shouldThrow` (\(TLS.HandshakeFailed _) -> True)
+
+        describe "runGBS" $ do
+            let swissnum = "hello world"
+            let CertificateChain [signedExactCert] = fst credential
+            let certificate = signedObject . getSigned $ signedExactCert
+            let nurl = NURLv1 (spkiFingerprint certificate) ("127.0.0.1", 0) swissnum
+            it "returns Left on connection errors" $ do
+                result <- runGBS nurl version
+                result
+                    `shouldSatisfy` ( \case
+                                        Left (ConnectionError _) -> True
+                                        _ -> False
+                                    )
+
+            it "returns Right on success" $ do
+                backend <- memoryBackend
+                ver <- withServerSocket $ \sock -> do
+                    Async.withAsync (runTLSSocket (tlsSettings certificatePath privateKeyPath) defaultSettings sock (Server.app backend)) $
+                        const $ do
+                            addr <- getSocketName sock
+                            runGBS nurl{nurlv1Address = bimap T.unpack fromIntegral $ addrToHostPort addr} version
+                ver `shouldSatisfy` isRight
+  where
+    settings = mkGBSManagerSettings (SPKIHash "just any hash") "swissnum"
+    request = defaultRequest
+
+    expectServerSuccess = id
+    expectServerFailure server = do
+        result <- try server
+        case result of
+            Left (_ :: SomeException) -> pure ()
+            Right r -> throwIO $ ExpectedFailure ("Expect the server to fail but it succeed with " <> show r)
+
+withServerSocket :: (Socket -> IO a) -> IO a
+withServerSocket action = do
+    sock <- socket AF_INET Stream 0
+    listen sock 1
+    r <- action sock
+    close' sock
+    pure r
+
+newtype ExpectedFailure = ExpectedFailure String deriving (Eq, Ord, Show)
+instance Exception ExpectedFailure
+
+addrToHostPort :: SockAddr -> (T.Text, Int)
+addrToHostPort (SockAddrInet port host) = (T.pack $ uncurry4 (printf "%d.%d.%d.%d") (hostAddressToTuple host), fromIntegral port)
+addrToHostPort (SockAddrInet6 port _flow host _scope) = (T.pack $ uncurry8 (printf "%x:%x:%x:%x:%x:%x:%x:%x") (hostAddress6ToTuple host), fromIntegral port)
+addrToHostPort (SockAddrUnix _path) = error "Cannot do TLS over a Unix socket"
+
+-- XXX get a Credential here and then use it to set up the TLS.Context
+-- ServerParams -> serverShared (Shared) -> sharedCredentials -> Credentials ([Credential]) -> (CertificateChain, PrivKey)
+-- ServerParams -> serverHooks (ServerHooks) -> onServerNameIndication -> return Credentials ([Credential]) -> (CertificateChain, PrivKey)
+withTlsServer :: TLS.Credentials -> LB.ByteString -> (IO () -> IO ()) -> (SockAddr -> IO a) -> IO a
+withTlsServer serverCredentials expectedBytes runServer clientApp = do
+    -- XXX safely clean up
+    bindAddr : _ <- getAddrInfo (Just hints) (Just "127.0.0.1") (Just "0")
+    sock <- openSocket bindAddr
+    bind sock (addrAddress bindAddr)
+    listen sock 1
+    boundAddr <- getSocketName sock
+
+    -- The server socket is bound and listening so it is safe to initiate a
+    -- connection now.  We'll get to handling the TLS connection next.
+    client <- Async.async (clientApp boundAddr)
+
+    -- Tests cover success and failure codepaths.  Let them make whatever
+    -- assertion they want about the server result.
+    () <- runServer $ SimpleTLS.accept serverParams sock serverApp
+
+    -- Let the client finish
+    Async.wait client
+  where
+    -- Serve a connection to the TLS server.
+    serverApp (ctx, _) = TLS.sendData ctx expectedBytes
+
+    hints =
+        defaultHints
+            { addrFlags = [AI_NUMERICHOST, AI_NUMERICSERV]
+            , addrSocketType = Stream
+            }
+    serverParams =
+        def
+            { TLS.serverShared =
+                def
+                    { TLS.sharedCredentials = serverCredentials
+                    }
+            , TLS.serverSupported =
+                def
+                    { TLS.supportedCiphers = ciphersuite_default
+                    }
+            }
+
+uncurry4 :: (a -> b -> c -> d -> e) -> (a, b, c, d) -> e
+uncurry4 z (a, b, c, d) = z a b c d
+
+uncurry8 :: (a -> b -> c -> d -> e -> f -> g -> h -> i) -> (a, b, c, d, e, f, g, h) -> i
+uncurry8 z (a, b, c, d, e, f, g, h) = z a b c d e f g h
diff --git a/test/Spec.hs b/test/Spec.hs
index 8d9d31a7463636c9b3b20018e4d15b8b707c39e3..53f227449651481a6b8236df6528e652b15e8408 100644
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -3,13 +3,15 @@ module Spec where
 import Test.Hspec
 
 import qualified CBORSpec as C
+import qualified ClientSpec as Client
 import qualified HTTPSpec as H
 import qualified MiscSpec as M
 import qualified SemanticSpec as S
 
 spec :: Spec
-spec = do
-    parallel $ describe "HTTP" H.spec
-    parallel $ describe "Misc" M.spec
-    parallel $ describe "Semantic" S.spec
-    parallel $ describe "CBOR" C.spec
+spec = parallel $ do
+    describe "HTTP" H.spec
+    describe "Misc" M.spec
+    describe "Semantic" S.spec
+    describe "CBOR" C.spec
+    describe "Client" Client.spec
diff --git a/test/Vectors.hs b/test/Vectors.hs
new file mode 100644
index 0000000000000000000000000000000000000000..b9f85cf464ff8b19bbc3659510949f4879a2bff6
--- /dev/null
+++ b/test/Vectors.hs
@@ -0,0 +1,64 @@
+module Vectors where
+
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Base64 as Base64
+import qualified Data.ByteString.Base64.URL as Base64URL
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import Data.X509 (Certificate, getSigned, signedObject)
+import Data.X509.Memory (readSignedObjectFromMemory)
+import Data.Yaml (FromJSON (..), ParseException, decodeEither', withObject, (.:))
+import TahoeLAFS.Internal.Client (SPKIHash (SPKIHash))
+
+-- | A single case of expected SPKI fingerprint calculation behavior.
+data SPKICase = SPKICase
+    { -- | The expected bytes representation of the SPKI information.
+      spkiExpected :: B.ByteString
+    , -- | The expected SPKI Fingerprint.
+      spkiExpectedHash :: SPKIHash
+    , -- | The certificate to operate on for this case.
+      spkiCertificate :: Certificate
+    }
+    deriving (Eq, Show)
+
+-- | A single possibly-successfully loaded SPKI fingerprint test case.
+data SPKICase' = SPKICase'
+    { spkiExpected' :: Either T.Text B.ByteString
+    , spkiExpectedHash' :: Either T.Text B.ByteString
+    , spkiCertificates' :: [Certificate]
+    }
+    deriving (Eq, Show)
+
+instance FromJSON SPKICase' where
+    parseJSON = withObject "SPKICase" $ \o ->
+        SPKICase'
+            <$> (Base64.decodeBase64 . T.encodeUtf8 <$> o .: "expected-spki")
+            <*> (Base64URL.decodeBase64 . T.encodeUtf8 <$> o .: "expected-hash")
+            <*> (fmap (signedObject . getSigned) . readSignedObjectFromMemory . T.encodeUtf8 <$> o .: "certificate")
+
+-- | Some possibly-successfully loaded SPKI fingerprint test cases.
+newtype SPKITestVector = SPKITestVector
+    { spkiTestVector :: [SPKICase']
+    }
+    deriving (Eq, Show)
+
+instance FromJSON SPKITestVector where
+    parseJSON = withObject "SPKITestVector" $ \o -> SPKITestVector <$> o .: "vector"
+
+-- | Attempt to load the SPKI Fingerprint test cases.
+loadSPKITestVector ::
+    -- | The YAML-serialized representation of the test cases.
+    B.ByteString ->
+    -- | The cases or an error if something went wrong.
+    Either LoadError [SPKICase]
+loadSPKITestVector = either (Left . YamlParseError) (traverse toSPKICase . spkiTestVector) . decodeEither'
+
+-- | Convert a possibly-successfully loaded SPKI fingerprint test case to a canonical form.
+toSPKICase :: SPKICase' -> Either LoadError SPKICase
+toSPKICase (SPKICase' (Right expected) (Right hash) [cert]) = Right $ SPKICase expected (SPKIHash hash) cert
+toSPKICase (SPKICase' (Left err) _ _) = Left $ TestVectorDataError $ "Error loading expected field: " <> T.pack (show err)
+toSPKICase (SPKICase' _ (Left err) _) = Left $ TestVectorDataError $ "Error loading hash field: " <> T.pack (show err)
+toSPKICase (SPKICase' _ _ certs) = Left $ TestVectorDataError $ "Error loading certs field: " <> T.pack (show certs)
+
+-- | Represent an error that was encountered while trying to load the test data.
+data LoadError = YamlParseError ParseException | TestVectorDataError T.Text deriving (Show)
diff --git a/test/data/certificate.pem b/test/data/certificate.pem
new file mode 100644
index 0000000000000000000000000000000000000000..d81466c35263984a1e0453352e7585083b96f437
--- /dev/null
+++ b/test/data/certificate.pem
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE-----
+MIIFbTCCA1WgAwIBAgIUKCE7U2P4crsJcoC352pjvYOV4YAwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCVVMxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAgFw0yMzA4MjExNzE5NTVaGA8yMTIz
+MDcyODE3MTk1NVowRTELMAkGA1UEBhMCVVMxEzARBgNVBAgMClNvbWUtU3RhdGUx
+ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCAiIwDQYJKoZIhvcN
+AQEBBQADggIPADCCAgoCggIBAN4WMHMG+rMEkVw6pXh247YohAh4Hwx3ctWUF5iS
+vI/m30Ib4rOkNaVeRuZftyDwmdnEhHtS1whs/k++PQsS1go2oqkTleyzVfYlfxYz
+Nd+db0OOYNBDZQ5+26e1KBq5l+cachhbRnm6l1UfrzLoN5Kvfv1uwyo+457wag98
+HWfgvNRI0Kwq8TI6c4AqSkFMIfvtsNqQONK1vNZG5tQcaIyA2lHPfYE34nWOJnVN
+5xheNcN3G/SuTVgRQ3jy7Bo9lvnbq3SjJulUazUrLWG+PwHvPv/FwGCFOybBCa2j
+n7qk1Gil44B6bw4rrIj/zbXxkO34d2aH2JsVTUgXmOhR27XQpz2Uuj15pDrFrIdC
+q0M+JGblI94KRYCiDCvOQX3YuzkpYkTXqmmhBC5DOqyj4Ov9Q0Q70FI+c1gE8mQh
+7eUMk0qG76jCeqnguyCKfhpX84kkQBycByBN/qgXUa71+XDErrLnAlgVh9nCAQFJ
+Vx6DAUtIILvuNkCB41AJG4Hz/WEztORaewRDehH99P4ULOG0VeF1BXoDnpWptD6w
+CON6ZpEgXdSEsZnlnWiBUvkFTQLpcUgeP6Naf1Dxwfbp/YDLPQ5s4DIoB/1lhBeT
+f2JZlgjy3Ya/LM9dQ35Le/D9omvjecy4aVvqPOihBHNNAIauInc0QRaSmM7g64D4
+Hh/9AgMBAAGjUzBRMB0GA1UdDgQWBBSogQZaLNNgPwIr0IkvhZKuwAg/TTAfBgNV
+HSMEGDAWgBSogQZaLNNgPwIr0IkvhZKuwAg/TTAPBgNVHRMBAf8EBTADAQH/MA0G
+CSqGSIb3DQEBCwUAA4ICAQCTDExMH2N+TQgV9XUyeFP01/Brm9UcP6m9Jz7ge8PI
+L6foOuN3kMOMX/dDo5rux2oq+8NkNTw4w7nd5PX5KmLV9KHXwNcfuOrXIHm4dbPC
+l/aoOUQFLuusQ1aBsBvVsn5AUYULOB8BaBG0iyvS6pdYVEZG1y6mfss2hIAOf6nF
+CesqGj84mDaUVnDtenvyOdeitJDMF0ueYQE7psp2ToySD85HRScNI2L1qNmcshAr
+Xk+qnahtwqNaq9Et0H0Fcvf+wjuHxWlUjZaFbz+vJLAc0vuSNjqZRTw5ZKQdqbRF
+rPdet73v8DeJIzwszaA6fwO7R8jOR5AwHDijdaf0Y7S6xJdNhqEEeEttR3FlccFX
+bNkIob1OG7kOY4ckTtTn7uQwdgdvYo3cfJZ9ZKUt7FAw2PwlbArbw2IysmobPksO
+X2Q96OQn91vV0p9y838s8dfKwDpOSsx6eCIF5FiV9X3DSxNVN3nwXZApdbYcJT/E
+sRkecWcM7dS5wvvnhxxEnq2cEnXm2S5lONOJc8ae/9hp0Z5SsIKgYpO0oTWwiMMS
+fo6BOYpQRf1iXdYb3x60O8wIATyxXU+9Ckh63cFQexxkXrlInvf+Iht5NA/qA6kw
+qMhAh80khUX2tXzHZLt+aH6aGKYfaBJHFr4/sfY/VyePZG7La2RgA8B+Pl6yyq6k
+gQ==
+-----END CERTIFICATE-----
diff --git a/test/data/private-key.pem b/test/data/private-key.pem
new file mode 100644
index 0000000000000000000000000000000000000000..7f36378609a9ebabe3f17434bf91bcf333e65290
--- /dev/null
+++ b/test/data/private-key.pem
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+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
+GmRLxqaoXjQIQuVYicGLUQ4ls59u
+-----END PRIVATE KEY-----
diff --git a/test/data/spki-hash-test-vectors.yaml b/test/data/spki-hash-test-vectors.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..33837a1eae816c8eaabd3813b0dfb7d9e9ca5138
--- /dev/null
+++ b/test/data/spki-hash-test-vectors.yaml
@@ -0,0 +1,80 @@
+vector:
+- expected-hash: >-
+    JIj6ezHkdSBlHhrnezAgIC_mrVQHy4KAFyL-8ZNPGPM
+  expected-spki: >-
+    MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv9vqtA8Toy9D6xLGq41iUafSiAXnuirWxML2ct/LAcGJzATg6JctmJxxZQL7vkmaFFPBF6Y39bOGbbECM2iQYn2Qemj5fl3IzKTnYLqzryGM0ZwwnNbPyetSe/sksAIYRLzn49d6l+AHR+DjGyvoLzIyGUTn41MTDafMNtPgWx1i+65lFW3GHYpEmugu4bjeUPizNja2LrqwvwFuYXwmKxbIMdioCoRvDGX9SI3/euFstuR4rbOEUDxniYRF5g6reP8UMF30zJzF5j0kyDg8Z5b1XpKFNZAeyRYxcs9wJCqVlP6BLPDnvNVpMXodnWLeTK+r6YWvGadGVufkYNC1PwIDAQAB
+  certificate: |
+    -----BEGIN CERTIFICATE-----
+    MIIDWTCCAkECFCf+I+3oEhTfqt+6ruH4qQ4Wst1DMA0GCSqGSIb3DQEBCwUAMGkx
+    CzAJBgNVBAYTAlpaMRAwDgYDVQQIDAdOb3doZXJlMRQwEgYDVQQHDAtFeGFtcGxl
+    dG93bjEcMBoGA1UECgwTRGVmYXVsdCBDb21wYW55IEx0ZDEUMBIGA1UEAwwLZXhh
+    bXBsZS5jb20wHhcNMjIwMzAyMTUyNTQ3WhcNMjMwMzAyMTUyNTQ3WjBpMQswCQYD
+    VQQGEwJaWjEQMA4GA1UECAwHTm93aGVyZTEUMBIGA1UEBwwLRXhhbXBsZXRvd24x
+    HDAaBgNVBAoME0RlZmF1bHQgQ29tcGFueSBMdGQxFDASBgNVBAMMC2V4YW1wbGUu
+    Y29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv9vqtA8Toy9D6xLG
+    q41iUafSiAXnuirWxML2ct/LAcGJzATg6JctmJxxZQL7vkmaFFPBF6Y39bOGbbEC
+    M2iQYn2Qemj5fl3IzKTnYLqzryGM0ZwwnNbPyetSe/sksAIYRLzn49d6l+AHR+Dj
+    GyvoLzIyGUTn41MTDafMNtPgWx1i+65lFW3GHYpEmugu4bjeUPizNja2LrqwvwFu
+    YXwmKxbIMdioCoRvDGX9SI3/euFstuR4rbOEUDxniYRF5g6reP8UMF30zJzF5j0k
+    yDg8Z5b1XpKFNZAeyRYxcs9wJCqVlP6BLPDnvNVpMXodnWLeTK+r6YWvGadGVufk
+    YNC1PwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQByrhn78GSS3dJ0pJ6czmhMX5wH
+    +fauCtt1+Wbn+ctTodTycS+pfULO4gG7wRzhl8KNoOqLmWMjyA2A3mon8kdkD+0C
+    i8McpoPaGS2wQcqC28Ud6kP9YO81YFyTl4nHVKQ0nmplT+eoLDTCIWMVxHHzxIgs
+    2ybUluAc+THSjpGxB6kWSAJeg3N+f2OKr+07Yg9LiQ2b8y0eZarpiuuuXCzWeWrQ
+    PudP0aniyq/gbPhxq0tYF628IBvhDAnr/2kqEmVF2TDr2Sm/Y3PDBuPY6MeIxjnr
+    ox5zO3LrQmQw11OaIAs2/kviKAoKTFFxeyYcpS5RuKNDZfHQCXlLwt9bySxG
+    -----END CERTIFICATE-----
+
+- expected-hash: >-
+    jIvdTaNKVK_iyt2EOMb0PwF23vpY3yfsQwbr5V2Rt1k
+  expected-spki: >-
+    MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxMjhLl8R6KX+/itDHCT/T7LQM1i9F6LHe3TW0KWY2FKC2Ov6sJi1pn4NM2qrlW3EUPhX4l0Ru0VE9ZJuwQB1nzFkZIP70Kr8MLmYBoDjWWXsxTiNG4Lj3ydMxBMq/LLSpgHYgb3+Hh+OQeByboW1nVWWm8+QjZNXHhMvRhJmYvyFi0VWoITe/L5R0ubMtGwZ5mal/z9OnvYcE+Jb4PUxiujDhhvAxr4acHscPDn8e4+HBswDSvIHwyxKkE/w6G0yiw736YUbGmxsThSqRqilujh3dAdIVJJxlxhHwrdUkdK/Eq96SOx/BB6M/M8n8KrRNgwuF25MsabRPphgT/l4M46ddyq4209skSnoa1uJdzfx7HQuWep2n0Nagu6WtcKtrzPI3/BKiOMzOcTNOI63VavCtn995CYY9aUoTpz/x/rlp/5TPM1KiaYMBaq+MneBtqlHyYEQUZP9l8QNtvMUO7nLYaYZhcs/QA+qmpJnxcK07njvmw6gh2oLXuvbUbohPVq/3dmRBdJh4tOZWtJsjFP0XYe41Hhw/sUSWXlJAPghLXBBbgAkkeyK5KatuvD7Lpfs/iuz17No1mo8MhLr3+EnzZ1JBuRo8Nksw4FX5ivZmJxt/HQ2UcQ9HZLejIZJbYBEpUu5hvaC0rOmWDWfftLAjD7DzDPu+u46ZNGa8ykCAwEAAQ==
+  certificate: |
+    -----BEGIN CERTIFICATE-----
+    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
+    -----END CERTIFICATE-----
+
+- expected-hash: >-
+    nG1UHCwz7nXHp2zMCiSfxRbCY29OK3RockkeOiw-t8A
+  expected-spki: >-
+    MCowBQYDK2VwAyEA6gbCgxeb9kkSDo4WbB76aTvBWnpyzColUKDxyDhPu94=
+  certificate: |
+    -----BEGIN CERTIFICATE-----
+    MIIBnzCCAVGgAwIBAgIUBM5d9fmVxhjKQod7TLp6Bb2vEd4wBQYDK2VwMEUxCzAJ
+    BgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5l
+    dCBXaWRnaXRzIFB0eSBMdGQwHhcNMjMwODIyMTI1NjE0WhcNMjQwODIxMTI1NjE0
+    WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwY
+    SW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMCowBQYDK2VwAyEA6gbCgxeb9kkSDo4W
+    bB76aTvBWnpyzColUKDxyDhPu96jUzBRMB0GA1UdDgQWBBQC8cbPWjZilcD4FSU/
+    J1sSNYwpAjAfBgNVHSMEGDAWgBQC8cbPWjZilcD4FSU/J1sSNYwpAjAPBgNVHRMB
+    Af8EBTADAQH/MAUGAytlcANBAGfmvq0a+Ip6nDBlj1tOpyJzcl1J+wj+4N72V23z
+    H1c75cXDrl9DMOqLwNVK9YD2wmaxPyEWO4tdth560Nir4QM=
+    -----END CERTIFICATE-----