diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,9 @@
+# Revision history for icloud-auth
+
+`icloud-auth` uses [PVP Versioning][1].
+
+## 0.1.0.0 -- 2026-07-20
+
+* Initial version.
+
+[1]: https://pvp.haskell.org
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2023, Tim Emiola
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Tim Emiola nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,92 @@
+# hstratus-auth — unofficial authentication for iCloud services
+
+`hstratus-auth` authenticates with iCloud using Apple ID credentials stored on
+disk.  The full sign-in flow — SRP credential exchange followed by any required
+two-factor (2FA) or legacy two-step (2SA) challenge — runs automatically,
+prompting the terminal for verification codes when needed.  On success it caches
+a session token for use with other iCloud services.
+
+
+## Disclaimer — use at your own risk
+
+- This library is **unofficial** and not supported by Apple.
+- The iCloud authentication protocol it uses is undocumented and may change
+  without notice.
+
+## Usage
+
+The same two steps — saving credentials and authenticating — are available
+programmatically.
+
+### Saving credentials
+
+Write a `credentials.json` file directly, or call `saveCredentials`:
+
+```haskell
+import Network.HStratus.Session (Credentials (..), saveCredentials)
+
+saveCreds :: IO ()
+saveCreds =
+  saveCredentials $ Credentials
+    { accountName = "your-apple-id@example.com"
+    , password    = "your-password"
+    }
+```
+
+### Authenticating
+
+Create an `Api` handle with `mkApi`, then call `login`:
+
+```haskell
+import Network.HStratus.Http (mkApi, login, AuthState (..))
+import Network.HStratus.Http.Endpoints (Realm (..))
+
+example :: IO ()
+example = do
+  api <- mkApi Usual  -- or China for mainland China accounts
+  result <- login api
+  case result of
+    Authenticated _session _accountData -> putStrLn "Authenticated!"
+    _                                   -> putStrLn "Unexpected result"
+```
+
+### Injectable callbacks
+
+Pass your own callbacks to `loginWith` to replace the interactive prompts —
+useful in automation or tests.  The snippet below shows the code-reader; the
+phone-selector and device-selector arguments follow the same pattern.
+
+```haskell
+import Network.HStratus.Http (loginWith)
+import qualified Data.Text.IO as Text
+
+exampleWith :: Api -> IO AuthState
+exampleWith api = loginWith readCode (\_ -> pure Nothing) chooseDevice api
+ where
+  readCode codeLen = do
+    Text.putStrLn $ "Enter the " <> Text.pack (show codeLen) <> "-digit code:"
+    Text.getLine
+  chooseDevice (d :| _) = pure d
+```
+
+If you already hold a `Requires2FA` or `Requires2SA` value from a prior call,
+resume with `completeTwoFactor` / `completeTwoFactorWith` or `complete2SA` /
+`complete2SAWith`.
+
+## Reauthentication
+
+The session token expires after a period set by iCloud (approximately two months
+at the time of writing).  When it does, authenticate again to refresh it.
+
+
+## CLI usage
+
+A command-line interface using this behaviour is provided by the [`hstratus`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#readme)
+package.  Use [`hstratus auth init`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-auth-init) and [`hstratus auth login`](https://github.com/adetokunbo/hstratus/tree/main/hstratus/#hstratus-auth-login) to save credentials and authenticate.
+
+
+
+---
+
+Apple and the Apple logo are trademarks of Apple Inc., registered in the U.S. and other countries and regions.
+iCloud is a service mark of Apple Inc., registered in the U.S. and other countries and regions.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,4 @@
+import Distribution.Simple
+
+
+main = defaultMain
diff --git a/hstratus-auth.cabal b/hstratus-auth.cabal
new file mode 100644
--- /dev/null
+++ b/hstratus-auth.cabal
@@ -0,0 +1,175 @@
+cabal-version:      3.0
+name:               hstratus-auth
+version:            0.1.0.0
+synopsis:           Authenticate with iCloud
+description:
+  Authenticate with iCloud using Apple ID credentials stored on disk.
+
+  The full sign-in flow — SRP credential exchange followed by any required
+  two-factor (2FA) or legacy two-step (2SA) challenge — is handled by a
+  single login call. On success the library caches a session token for use
+  with other iCloud services.
+
+  This library is unofficial and not supported by Apple. It may break
+  without warning if Apple changes their authentication protocol.
+license:            BSD-3-Clause
+license-file:       LICENSE
+maintainer:         Tim Emiola <adetokunbo@emio.la>
+category:           Network
+build-type:         Simple
+tested-with:
+    GHC == 9.2.8
+  , GHC == 9.4.8
+  , GHC == 9.6.7
+  , GHC == 9.8.4
+  , GHC == 9.10.2
+  , GHC == 9.12.1
+extra-doc-files:
+  ChangeLog.md
+  README.md
+data-files:         testdata/*.json
+
+source-repository head
+  type:     git
+  location: https://github.com/adetokunbo/hstratus.git
+  subdir:   hstratus-auth
+
+library hstratus-auth-cli
+  visibility:       public
+  exposed-modules:  Network.HStratus.Http.Cli
+  hs-source-dirs:   src-cli
+  build-depends:
+    , base                 >=4.12 && <5
+    , directory            >=1.3 && <1.4
+    , filepath             >=1.4 && <1.6
+    , http-client-tls      >=0.3 && <0.4
+    , hstratus-auth
+    , optparse-applicative >=0.18 && <0.19
+    , xdg-basedir          >=0.2 && <0.3
+  default-language: Haskell2010
+  ghc-options:      -Wall -Wincomplete-uni-patterns -Wpartial-fields -fwarn-tabs
+
+library
+  exposed-modules:  Network.HStratus.Http
+                    Network.HStratus.Http.Endpoints
+                    Network.HStratus.Http.Common
+                    Network.HStratus.Session
+                    Network.HStratus.Trust
+  other-modules:    Network.HStratus.Internal.Http.Api
+                    Network.HStratus.Internal.Http.Login
+                    Network.HStratus.Internal.Http.Signin
+  hs-source-dirs:   src
+  build-depends:
+    , aeson                >=2.0 && <2.3
+    , aeson-casing         >=0.1 && <0.3
+    , base                 >=4.12 && <5
+    , base16-bytestring    >=1.0 && <1.1
+    , base64-bytestring    >=1.0 && <2.1
+    , bytestring           >=0.10.8 && <0.11 || >=0.11.3 && <0.13
+    , case-insensitive     >=1.2 && <1.3
+    , containers           >=0.6 && <0.8
+    , crypto-srp           >=0.1 && <0.2
+    , cryptohash-sha256    >=0.11 && <0.12
+    , directory            >=1.3 && <1.4
+    , filepath             >=1.4 && <1.6
+    , http-client          >=0.5 && <0.8
+    , http-client-tls      >=0.3 && <0.4
+    , http-types           >=0.12.1 && <0.13
+    , random               >=1.1 && <1.4
+    , simple-prompt        >=0.2 && <0.3
+    , string-conv          >=0.1 && <0.3
+    , text                 >=1.2.3 && <2.2
+    , time                 >=1.8 && <1.15
+    , transformers         >=0.5 && <0.7
+    , unix                 >=2.7 && <2.9
+    , uuid                 >=1.3 && <1.4
+    , web-cookiejar        >=0.1.3 && <0.2
+    , xdg-basedir          >=0.2 && <0.3
+    , hstratus-auth:hstratus-auth-internal
+  default-language: Haskell2010
+  ghc-options:      -Wall -Wincomplete-uni-patterns -Wpartial-fields -fwarn-tabs
+
+
+library hstratus-auth-internal
+  exposed-modules:  Network.HStratus.Internal.Endpoints
+                    Network.HStratus.Internal.Http
+                    Network.HStratus.Internal.HttpErrors
+                    Network.HStratus.Internal.LoginFSM
+                    Network.HStratus.Internal.PBKDF2
+                    Network.HStratus.Internal.Session
+                    Network.HStratus.Internal.Trust
+  hs-source-dirs:   src-internal
+  build-depends:
+    , aeson                >=2.0   && <2.3
+    , aeson-casing         >=0.1 && <0.3
+    , base                 >=4.12 && <5
+    , base16-bytestring    >=1.0 && <1.1
+    , bytestring           >=0.10.8 && <0.11 || >=0.11.3 && <0.13
+    , case-insensitive     >=1.2 && <1.3
+    , containers           >=0.6 && <0.8
+    , crypto-srp           >=0.1 && <0.2
+    , directory            >=1.3 && <1.4
+    , filepath             >=1.4 && <1.6
+    , http-client          >=0.5 && <0.8
+    , http-types           >=0.12.1 && <0.13
+    , simple-prompt        >=0.2 && <0.3
+    , string-conv          >=0.1 && <0.3
+    , text                 >=1.2.3 && <2.2
+    , unix                 >=2.7 && <2.9
+    , uuid                 >=1.3 && <1.4
+    , xdg-basedir          >=0.2 && <0.3
+  default-language: Haskell2010
+  ghc-options:      -Wall -Wincomplete-uni-patterns -Wpartial-fields -fwarn-tabs
+
+
+test-suite test
+  type:             exitcode-stdio-1.0
+  main-is:          Spec.hs
+  hs-source-dirs:   test
+  autogen-modules:  Paths_hstratus_auth
+  other-modules:    HStratus.ApiLoggerSpec
+                    HStratus.Examples
+                    HStratus.Http.CliSpec
+                    HStratus.HttpMockSpec
+                    HStratus.HttpSpec
+                    HStratus.Http.EndpointsSpec
+                    HStratus.Http.ErrorsSpec
+                    HStratus.Http.HeadersSpec
+                    HStratus.LoginFSMSpec
+                    HStratus.Mock
+                    HStratus.PBKDF2Spec
+                    HStratus.SessionSpec
+                    HStratus.TrustSpec
+                    Paths_hstratus_auth
+  default-language: Haskell2010
+  ghc-options:      -threaded -rtsopts -with-rtsopts=-N -Wall -fwarn-tabs
+  build-depends:
+    , aeson
+    , base
+    , base16-bytestring
+    , bytestring
+    , containers
+    , cryptohash-sha1      >=0.11 && <0.12
+    , cryptohash-sha256    >=0.11 && <0.12
+    , cryptohash-sha512    >=0.11 && <0.12
+    , crypto-srp           >=0.1 && <0.2
+    , directory            >=1.3.6  && <1.5
+    , filepath             >=1.4 && <1.6
+    , benri-hspec          >=0.1 && <0.3
+    , hspec                >=2.1 && < 3.0
+    , http-client          >=0.5 && <0.8
+    , http-types
+    , QuickCheck           >= 2.13 && < 2.16
+    , temporary            >= 1.2 && < 1.4
+    , hstratus-auth
+    , hstratus-auth:hstratus-auth-cli
+    , optparse-applicative >=0.18 && <0.19
+    , silently             >= 1.2 && < 1.3
+    , string-conv
+    , text
+    , wai                  >=3.2 && <3.3
+    , warp                 >=3.2 && <3.5
+    , main-tester          >= 0.2 && < 0.3
+    , unix                 >=2.7 && <2.9
+    , hstratus-auth:hstratus-auth-internal
+
diff --git a/src-cli/Network/HStratus/Http/Cli.hs b/src-cli/Network/HStratus/Http/Cli.hs
new file mode 100644
--- /dev/null
+++ b/src-cli/Network/HStratus/Http/Cli.hs
@@ -0,0 +1,131 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Network.HStratus.Http.Cli
+  ( -- * Common CLI options
+    CommonOpts (..)
+  , commonOptsParser
+
+    -- * Log target resolution
+  , resolveLogTarget
+  , defaultLogFile
+
+    -- * Logger selection
+  , mkLoggerFor
+
+    -- * Authenticated API runner
+  , runWithApi
+
+    -- * Error handler
+  , onServiceError
+  )
+where
+
+import Control.Exception (catch, displayException)
+import Network.HStratus.Http
+  ( Api
+  , ApiLogger
+  , AuthError
+  , AuthState (..)
+  , HStratusError
+  , fileLogger
+  , login
+  , mkApiWith
+  , redactingLogger
+  , verboseLogger
+  , withLogger
+  )
+import Network.HStratus.Http.Endpoints (Realm (..), realmEndpoints)
+import Network.HStratus.Session (AccountData, Session, loadSession)
+import Network.HTTP.Client.TLS (newTlsManager)
+import Options.Applicative
+import System.Directory (createDirectoryIfMissing)
+import System.Environment.XDG.BaseDir (getUserCacheDir)
+import System.Exit (exitFailure)
+import System.FilePath ((</>))
+import System.IO (Handle, IOMode (..), stdout, withFile)
+
+
+-- | Options shared by all icloud CLI commands.
+data CommonOpts = CommonOpts
+  { optChina :: Bool
+  -- ^ Use mainland China endpoints instead of the worldwide endpoints.
+  , optLog :: Bool
+  -- ^ Append HTTP exchanges to the default log file.
+  , optLogFile :: Maybe FilePath
+  -- ^ Append HTTP exchanges to this file instead of the default.
+  , optLogBodies :: Bool
+  -- ^ Include request bodies in the HTTP exchange log.
+  , optRedact :: Bool
+  -- ^ Redact sensitive headers (tokens, cookies) in the log.
+  }
+  deriving (Eq, Show)
+
+
+-- | Parser for 'CommonOpts'.
+commonOptsParser :: Parser CommonOpts
+commonOptsParser =
+  CommonOpts
+    <$> switch (long "china" <> help "Use mainland China endpoints")
+    <*> switch (long "log" <> help "Append HTTP exchanges to the default log file")
+    <*> optional
+      (strOption (long "log-file" <> metavar "FILE" <> help "Append HTTP exchanges to FILE"))
+    <*> switch (long "log-bodies" <> help "Include request bodies in the HTTP exchange log")
+    <*> switch (long "redact" <> help "Redact sensitive headers (tokens, cookies) in the log")
+
+
+-- | Resolve the log file path from 'CommonOpts', or 'Nothing' if logging is disabled.
+resolveLogTarget :: CommonOpts -> IO (Maybe FilePath)
+resolveLogTarget CommonOpts{optLogFile = Just fp} = pure (Just fp)
+resolveLogTarget CommonOpts{optLog = True} = Just <$> defaultLogFile
+resolveLogTarget _ = pure Nothing
+
+
+-- | Default log file path: @~\/.cache\/hs-icloud\/requests.log@.
+defaultLogFile :: IO FilePath
+defaultLogFile = do
+  dir <- getUserCacheDir "hs-icloud"
+  createDirectoryIfMissing True dir
+  pure (dir </> "requests.log")
+
+
+-- | Select the appropriate logger constructor from 'CommonOpts'.
+mkLoggerFor :: CommonOpts -> Handle -> ApiLogger
+mkLoggerFor CommonOpts{optRedact = True} = redactingLogger
+mkLoggerFor CommonOpts{optLogBodies = True} = verboseLogger
+mkLoggerFor _ = fileLogger
+
+
+{- | Authenticate and run an action with the resulting 'Api'.
+
+Handles session loading, TLS manager creation, logger wiring, and catches
+'AuthError'. Additional error types should be caught by the caller.
+-}
+runWithApi
+  :: CommonOpts
+  -> (AccountData -> Session -> Api -> IO ())
+  -> IO ()
+runWithApi opts runAction = do
+  session <- loadSession
+  mgr <- newTlsManager
+  let realm = if optChina opts then China else Usual
+  api0 <- mkApiWith session (realmEndpoints realm) mgr
+  mbLogPath <- resolveLogTarget opts
+  let mkLogger' = mkLoggerFor opts
+      run api = do
+        result <- login api
+        case result of
+          Authenticated sess ad -> runAction ad sess api
+          _ -> putStrLn "Not authenticated — run 'hstratus-auth login' first." >> exitFailure
+      go = case mbLogPath of
+        Just fp -> withFile fp AppendMode $ \h -> run (withLogger (mkLogger' h) api0)
+        Nothing
+          | optLogBodies opts && not (optRedact opts) -> run (withLogger (mkLogger' stdout) api0)
+          | otherwise -> run api0
+  go `catch` onServiceError @AuthError
+
+
+-- | Print a service error and exit. Use as the catch handler in CLI wrappers.
+onServiceError :: (HStratusError e) => e -> IO a
+onServiceError e = putStrLn ("Error: " <> displayException e) >> exitFailure
diff --git a/src-internal/Network/HStratus/Internal/Endpoints.hs b/src-internal/Network/HStratus/Internal/Endpoints.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Endpoints.hs
@@ -0,0 +1,296 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : Network.HStratus.Internal.Endpoints
+Copyright   : (c) 2025 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+-}
+module Network.HStratus.Internal.Endpoints
+  ( -- * Types
+    Endpoints (..)
+  , Realm (..)
+
+    -- * Region selection
+  , realmEndpoints
+
+    -- * Request builders
+  , signinInitBase
+  , signinCompleteBase
+  , validateBase
+  , accountLoginBase
+  , twoSvTrust
+  , twoFaOptionsBase
+  , verifySecurityCodeReq
+  , validateVerification
+  , sendVerification
+  , listDevices
+
+    -- * Request modifiers
+  , extendPath
+  , toPut
+  , withHeaders
+  , withBody
+  , withAcceptJson
+  , withWidgetKey
+  , withAppleOauthHeaders
+
+    -- * Header helpers
+  , homeHeaders
+  , icloudBrowserHeaders
+
+    -- * Shared service utilities
+  , icloudHome
+  , stripTrailingSlash
+  , lookupWebservice
+  )
+where
+
+import Control.Exception (throwIO)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Char8 as BS8
+import qualified Data.ByteString.Lazy as LBS
+import Data.CaseInsensitive (mk)
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.String.Conv (toS)
+import Data.Text (Text)
+import Network.HStratus.Internal.HttpErrors (AuthError (..))
+import Network.HStratus.Internal.Session (Webservice (..))
+import Network.HTTP.Client
+  ( Request (..)
+  , RequestBody (..)
+  , defaultRequest
+  , parseRequest
+  )
+import Network.HTTP.Types
+  ( Header
+  , HeaderName
+  , RequestHeaders
+  , hAccept
+  , hReferer
+  , hUserAgent
+  , methodGet
+  , methodPost
+  , methodPut
+  )
+
+
+-- | @RequestHeaders@ that include the @Endpoint@ @home@
+homeHeaders :: Endpoints -> RequestHeaders
+homeHeaders ep = [(hOrigin, epHome ep), (hReferer, epHome ep <> "/")]
+
+
+commonHeaders :: Endpoints -> RequestHeaders
+commonHeaders ep = userAgent : homeHeaders ep
+
+
+-- | Construct a new @Request@ with that path changed by adding a suffix
+extendPath :: Request -> ByteString -> Request
+extendPath req suffix = req{path = path req <> suffix}
+
+
+-- | Construct a new @Request@ with the method changed to @GET@
+toGet :: Request -> Request
+toGet req = req{method = methodGet}
+
+
+-- | Construct a new @Request@ with the method changed to @PUT@
+toPut :: Request -> Request
+toPut req = req{method = methodPut}
+
+
+data Endpoints = Endpoints
+  { epHome :: !ByteString
+  -- ^ home origin, e.g. @https://www.icloud.com@; used in @Origin@ and @Referer@ headers
+  , epAuth :: !Request
+  -- ^ base request for the authentication endpoint (@idmsa.apple.com@)
+  , epSetup :: !Request
+  -- ^ base request for the setup\/account endpoint (@setup.icloud.com@)
+  , epWidgetKey :: !ByteString
+  -- ^ value sent as @X-Apple-Widget-Key@ and @X-Apple-OAuth-Client-Id@; override if Apple rotates it
+  }
+
+
+data Realm = China | Usual
+  deriving (Eq, Show)
+
+
+realmEndpoints :: Realm -> Endpoints
+realmEndpoints China = chinaEndpoints
+realmEndpoints Usual = usualEndpoints
+
+
+-- | The iCloud home origin used in @Origin@ and @Referer@ headers.
+icloudHome :: ByteString
+icloudHome = "https://www.icloud.com"
+
+
+usualEndpoints :: Endpoints
+usualEndpoints =
+  Endpoints
+    { epHome = icloudHome
+    , epAuth = authReq
+    , epSetup = setupReq
+    , epWidgetKey = iCloudKey
+    }
+
+
+chinaEndpoints :: Endpoints
+chinaEndpoints =
+  Endpoints
+    { epHome = "https://www.icloud.com.cn"
+    , epAuth = authReq -- idmsa.apple.com is Apple's global SRP endpoint; not region-specific
+    , epSetup = setupReq{host = "setup.icloud.com.cn"}
+    , epWidgetKey = iCloudKey
+    }
+
+
+apiRequest :: Request
+apiRequest =
+  defaultRequest
+    { secure = True
+    , port = 443
+    , method = methodPost
+    }
+
+
+authReq :: Request
+authReq = apiRequest{host = "idmsa.apple.com", path = "/appleauth/auth"}
+
+
+setupReq :: Request
+setupReq = apiRequest{host = "setup.icloud.com", path = "/setup/ws/1"}
+
+
+appleOauthHeaders :: ByteString -> [Header]
+appleOauthHeaders key =
+  [ ("X-Apple-OAuth-Client-Id", key)
+  , ("X-Apple-OAuth-Client-Type", "firstPartyAuth")
+  , ("X-Apple-OAuth-Redirect-URI", "https://www.icloud.com")
+  , ("X-Apple-OAuth-Require-Grant-Code", "true")
+  , ("X-Apple-OAuth-Response-Mode", "web_message")
+  , ("X-Apple-OAuth-Response-Type", "code")
+  , ("X-Apple-Widget-Key", key)
+  ]
+
+
+iCloudKey :: ByteString
+iCloudKey = "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d"
+
+
+browserAgent :: ByteString
+browserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"
+
+
+userAgent :: Header
+userAgent = (hUserAgent, browserAgent)
+
+
+acceptJson :: Header
+acceptJson = (hAccept, "application/json")
+
+
+-- | build the basic @Request@ to make the initiate signin
+signinInitBase :: Endpoints -> Request
+signinInitBase = (`extendPath` "/signin/init") . epAuth
+
+
+-- | build the basic @Request@ to complete signin
+signinCompleteBase :: Endpoints -> Request
+signinCompleteBase =
+  let
+    withQuery x = x{queryString = "isRememberMeEnabled=true"}
+   in
+    withQuery . (`extendPath` "/signin/complete") . epAuth
+
+
+-- | build the basic @Request@ to that validates user credentials
+validateBase :: Endpoints -> Request
+validateBase ep = withHeaders (commonHeaders ep) $ (`extendPath` "/validate") $ epSetup ep
+
+
+-- | build the basic @Request@ to that performs login
+accountLoginBase :: Endpoints -> Request
+accountLoginBase = (`extendPath` "/accountLogin") . epSetup
+
+
+-- | build the basic @Request@ to that makes the saved credentials trusted
+twoSvTrust :: Endpoints -> Request
+twoSvTrust = (`extendPath` "/2sv/trust") . toGet . epAuth
+
+
+-- | build the @Request@ to fetch the 2FA options after the 409 from signin/complete
+twoFaOptionsBase :: Endpoints -> Request
+twoFaOptionsBase = toGet . epAuth
+
+
+-- | build the @Request@ to that verifies makes a security code
+verifySecurityCodeReq :: Text -> Endpoints -> Request
+verifySecurityCodeReq codeType =
+  (`extendPath` ("/verify/" <> toS codeType <> "/securitycode")) . epAuth
+
+
+validateVerification :: Endpoints -> Request
+validateVerification = (`extendPath` "/validateVerificationCode") . epSetup
+
+
+sendVerification :: Endpoints -> Request
+sendVerification = (`extendPath` "/sendVerificationCode") . epSetup
+
+
+listDevices :: Endpoints -> Request
+listDevices = (`extendPath` "/listDevices") . toGet . epSetup
+
+
+withHeaders :: RequestHeaders -> Request -> Request
+withHeaders newHeaders req = req{requestHeaders = newHeaders <> requestHeaders req}
+
+
+withBody :: LBS.LazyByteString -> Request -> Request
+withBody b req = req{requestBody = RequestBodyLBS b}
+
+
+hOrigin :: HeaderName
+hOrigin = mk "Origin"
+
+
+withAcceptJson :: RequestHeaders -> RequestHeaders
+withAcceptJson = (acceptJson :)
+
+
+withWidgetKey :: ByteString -> RequestHeaders -> RequestHeaders
+withWidgetKey key = (("X-Apple-Widget-Key", key) :)
+
+
+withAppleOauthHeaders :: ByteString -> RequestHeaders -> RequestHeaders
+withAppleOauthHeaders key = (appleOauthHeaders key <>)
+
+
+-- | Standard browser-style headers sent with every iCloud service request.
+icloudBrowserHeaders :: RequestHeaders
+icloudBrowserHeaders =
+  [ acceptJson
+  , userAgent
+  , (hOrigin, icloudHome)
+  , (hReferer, icloudHome <> "/")
+  ]
+
+
+-- | Strip a trailing @/@ from a strict 'ByteString' path.
+stripTrailingSlash :: ByteString -> ByteString
+stripTrailingSlash bs
+  | not (BS8.null bs) && BS8.last bs == '/' = BS8.init bs
+  | otherwise = bs
+
+
+{- | Look up a service URL by key in the webservices map and parse it into a
+'Request'.  Fails with an informative message if the key is absent.
+-}
+lookupWebservice :: Text -> Map Text Webservice -> IO Request
+lookupWebservice key ws =
+  case Map.lookup key ws of
+    Nothing -> throwIO $ WebserviceNotFound key
+    Just (Webservice _ (Just "inactive")) -> throwIO $ WebserviceNotFound key
+    Just (Webservice url _) -> parseRequest (toS url)
diff --git a/src-internal/Network/HStratus/Internal/Http.hs b/src-internal/Network/HStratus/Internal/Http.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Http.hs
@@ -0,0 +1,144 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_HADDOCK prune #-}
+
+{- |
+Module      : Network.HStratus.Internal.Http
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Internal HTTP request body builders and SRP authentication context types.
+-}
+module Network.HStratus.Internal.Http
+  ( validateSetupBody
+  , phoneCodeBody
+  , phoneTriggerBody
+  , needsRetry
+  , PasswordProtocol (..)
+  , KeyDeriver (..)
+  , SrpContext (..)
+  , hCounter
+  , hCountry
+  , hSessionId
+  , hSessionToken
+  , hTrustToken
+  )
+where
+
+import Crypto.SRP
+  ( FromClient (..)
+  , FromServer (..)
+  , XCalculator (..)
+  , hashMany
+  , hashText
+  )
+import Data.Aeson (FromJSON (..), Value (..), withText)
+import Data.Aeson.KeyMap (fromList)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Base16 as Base16
+import Data.CaseInsensitive (mk)
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import Data.Word (Word64)
+import Network.HStratus.Internal.PBKDF2 (FancyPseudoRandomF, deriveKey)
+import Network.HStratus.Internal.Trust (Setup2SADevice (..), TrustedPhone (..))
+import Network.HTTP.Types.Header (HeaderName)
+
+
+-- | Models the known values of password protocol
+data PasswordProtocol
+  = -- | legacy @s2k_fo@ protocol: password is hex-encoded before hashing
+    Old
+  | -- | current @s2k@ protocol: password is hashed directly
+    New
+  deriving (Eq, Show)
+
+
+instance FromJSON PasswordProtocol where
+  parseJSON =
+    let fromText "s2k" = Right New
+        fromText "s2k_fo" = Right Old
+        fromText alt = Left $ "unknown PasswordProtocol: " ++ show alt
+     in withText "PasswordProtocol" $ either fail pure . fromText
+
+
+-- | Data used during key derivation and verification
+data KeyDeriver = KeyDeriver
+  { kdTag :: !Text
+  -- ^ SRP session tag from Apple's server response
+  , kdIterations :: !Word64
+  -- ^ PBKDF2 iteration count from Apple's server response
+  , kdProtocol :: !PasswordProtocol
+  -- ^ password hashing protocol in use for this session
+  , kdWrappedF :: !FancyPseudoRandomF
+  -- ^ PBKDF2 pseudo-random function, pre-wrapped with the negotiated hash algorithm
+  }
+
+
+instance XCalculator KeyDeriver where
+  calcX = calcXUsingKeyDeriver
+
+
+calcXUsingKeyDeriver :: KeyDeriver -> FromClient -> FromServer -> BS.ByteString
+calcXUsingKeyDeriver kd fc fs =
+  let FromServer{fsSalt, fsKnownAlgorithm = hashAlgo} = fs
+      h = hashMany hashAlgo
+      KeyDeriver{kdIterations = count, kdWrappedF, kdProtocol} = kd
+      useProtocol Old = Base16.encode
+      useProtocol New = id
+      hashed = useProtocol kdProtocol $ hashText hashAlgo $ fcPassword fc
+      reallyHashed = deriveKey kdWrappedF hashed fsSalt count
+   in h [fsSalt, h [":", reallyHashed]]
+
+
+-- | Bundles the SRP client\/server data and key deriver for a single auth attempt
+data SrpContext = SrpContext
+  { srpFromClient :: !FromClient
+  -- ^ client-side SRP values (public key, password verifier input)
+  , srpFromServer :: !FromServer
+  -- ^ server-side SRP values (salt, public key, hash algorithm)
+  , srpKeyDeriver :: !KeyDeriver
+  -- ^ key derivation parameters negotiated during SRP init
+  }
+
+
+-- | @HeaderName@s used to capture session info from HTTP responses
+hCountry, hSessionId, hSessionToken, hTrustToken, hCounter :: HeaderName
+hCountry = mk "X-Apple-ID-Account-Country"
+hSessionId = mk "X-Apple-ID-Session-Id"
+hSessionToken = mk "X-Apple-Session-Token"
+hTrustToken = mk "X-Apple-TwoSV-Trust-Token"
+hCounter = mk "scnt"
+
+
+-- | Build the JSON body to submit a legacy 2SA verification code for a given device.
+validateSetupBody :: Setup2SADevice -> Text -> Value
+validateSetupBody (Setup2SADevice fields) code =
+  Object $ fields <> fromList [("verificationCode", String code), ("trustBrowser", Bool True)]
+
+
+-- | Build the JSON body to trigger an SMS code to the given phone
+phoneTriggerBody :: TrustedPhone -> Value
+phoneTriggerBody tp =
+  Object $
+    fromList
+      [ ("phoneNumber", Object $ fromList [("id", Number $ fromIntegral $ tpnId tp)])
+      , ("mode", String $ fromMaybe "sms" $ tpnPushMode tp)
+      ]
+
+
+-- | @True@ when the HTTP status code warrants a single automatic retry (421, 450, or 500).
+needsRetry :: Int -> Bool
+needsRetry status = status == 421 || status == 450 || status == 500
+
+
+-- | Build the JSON body to verify an SMS code received on the given phone
+phoneCodeBody :: TrustedPhone -> Text -> Value
+phoneCodeBody tp code =
+  Object $
+    fromList
+      [ ("phoneNumber", Object $ fromList [("id", Number $ fromIntegral $ tpnId tp)])
+      , ("securityCode", Object $ fromList [("code", String code)])
+      , ("mode", String $ fromMaybe "sms" $ tpnPushMode tp)
+      ]
diff --git a/src-internal/Network/HStratus/Internal/HttpErrors.hs b/src-internal/Network/HStratus/Internal/HttpErrors.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/HttpErrors.hs
@@ -0,0 +1,134 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : Network.HStratus.Internal.HttpErrors
+Copyright   : (c) 2022 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+
+Datatypes that model the structured errors returned by the iCloud API.
+-}
+module Network.HStratus.Internal.HttpErrors
+  ( -- * API response wrapper
+    ApiResponse (..)
+
+    -- * API error embedded in 'ApiResponse'
+  , ApiError (..)
+
+    -- * Public exception type
+  , AuthError (..)
+
+    -- * Common service-error marker
+  , HStratusError
+
+    -- * Extracting results
+  , extractOr
+  )
+where
+
+import Control.Applicative (Alternative (..), (<|>))
+import Control.Exception (Exception, throwIO)
+import Data.Aeson
+  ( FromJSON (..)
+  , Object
+  , withObject
+  , (.:)
+  , (.:?)
+  )
+import Data.Aeson.KeyMap (member)
+import Data.Aeson.Types (Parser)
+import Data.Text (Text)
+
+
+-- | Represents an API response that may succeed or fail with @ApiError@
+data ApiResponse a = Failed !ApiError | Succeeded !a
+  deriving (Eq, Show)
+
+
+instance (FromJSON a) => FromJSON (ApiResponse a) where
+  parseJSON v = (Failed <$> parseJSON v) <|> (Succeeded <$> parseJSON v)
+
+
+-- | Represents an API response that reports a failure.
+data ApiError
+  = ApiError
+  { aeReason :: !Text
+  , aeCode :: !(Maybe Text)
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON ApiError where
+  parseJSON = withObject "ApiError" parseApiError
+
+
+-- | Structured errors thrown by the iCloud authentication layer
+data AuthError
+  = -- | The supplied credentials were rejected.
+    InvalidCredentials
+  | -- | The account has been locked due to too many failed sign-in attempts.
+    AccountLocked
+  | -- | The server requires the user to accept updated privacy terms before continuing.
+    PrivacyAgreementRequired
+  | -- | Session credentials were absent when the login flow required them.
+    CredentialsMissing
+  | -- | The artifact directory could not be created; the 'FilePath' names the directory.
+    ArtifactDirCreationFailed !FilePath
+  | -- | The SRP key exchange failed due to an invalid server public value.
+    SrpProtocolError
+  | -- | Two-factor authentication is locked after too many incorrect code attempts.
+    TwoFactorLocked
+  | -- | Two-factor authentication is still required after a verification attempt.
+    TwoFactorStillRequired
+  | -- | A required iCloud webservice URL was absent from 'AccountData'; the 'Text' is the service key.
+    WebserviceNotFound !Text
+  | -- | The API returned a structured service error with a reason and an optional error code.
+    ServiceError !Text !(Maybe Text)
+  | -- | An HTTP response that could not be interpreted; the 'Text' describes the failure.
+    UnexpectedResponse !Text
+  deriving (Eq, Show)
+
+
+instance Exception AuthError
+
+
+{- | Marker class for exceptions thrown by iCloud service libraries.
+
+Declare an instance for each library-level error type so that 'onServiceError'
+in "Network.HStratus.Http.Cli" can be used as a uniform catch handler.
+-}
+class (Exception e) => HStratusError e
+
+
+instance HStratusError AuthError
+
+
+-- | Extract the result from an 'ApiResponse', throwing 'ServiceError' on failure.
+extractOr :: ApiResponse a -> IO a
+extractOr (Succeeded x) = pure x
+extractOr (Failed x) = throwIO $ ServiceError (aeReason x) (aeCode x)
+
+
+{-
+In python, this looks like:
+
+   if isinstance(data, dict):
+       reason = data.get("errorMessage")
+       reason = reason or data.get("reason")
+       reason = reason or data.get("errorReason")
+       if not reason and isinstance(data.get("error"), str):
+           reason = data.get("error")
+       if not reason and data.get("error"):
+           reason = "Unknown reason"
+
+       code = data.get("errorCode")
+       if not code and data.get("serverErrorCode"):
+           code = data.get("serverErrorCode")
+-}
+parseApiError :: Object -> Parser ApiError
+parseApiError o =
+  let reason = o .: "errorMessage" <|> o .: "reason" <|> o .: "errorReason" <|> orError
+      hasError = member "error" o
+      orError = o .: "error" <|> (if hasError then pure "unknown error" else empty)
+      code = o .: "errorCode" <|> o .:? "serverErrorCode"
+   in ApiError <$> reason <*> code
diff --git a/src-internal/Network/HStratus/Internal/LoginFSM.hs b/src-internal/Network/HStratus/Internal/LoginFSM.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/LoginFSM.hs
@@ -0,0 +1,348 @@
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# OPTIONS_HADDOCK prune #-}
+
+{- |
+Module      : Network.HStratus.Internal.LoginFSM
+Copyright   : (c) 2025 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+
+Provides datatypes that represent the Finite State Machine that specifies the
+Login process
+-}
+module Network.HStratus.Internal.LoginFSM where
+
+import Data.Functor ((<&>))
+import Data.Kind (Type)
+import Data.List.NonEmpty (NonEmpty)
+import Data.Text (Text)
+import Data.Word (Word8)
+import Network.HStratus.Internal.Http (SrpContext (..))
+import Network.HStratus.Internal.Session (AccountData, Credentials, SavedHeaders)
+import Network.HStratus.Internal.Trust (Setup2SADevice, TrustData, TrustedPhone)
+
+
+-- | Configuration for the 2FA challenge process.
+data TwoFaConfig = TwoFaConfig
+  { tfcPickPhone :: TrustData -> IO (Maybe TrustedPhone)
+  -- ^ select a phone to receive an SMS code, or 'Nothing' to use a trusted device push
+  , tfcReadCode :: Word8 -> IO Text
+  -- ^ prompt the user for the verification code; receives the expected digit count
+  }
+
+
+-- | Configuration for the 2SA challenge process.
+data TwoSaConfig = TwoSaConfig
+  { tscPickDevice :: NonEmpty Setup2SADevice -> IO Setup2SADevice
+  -- ^ select the device to receive a verification code
+  , tscReadCode :: IO Text
+  -- ^ prompt the user for the verification code
+  }
+
+
+{- | @LoginEvent@ represents the valid events of the Login FSM.
+
+Each event is represent by a typeclass function that is constrained
+to go between valid states
+-}
+class LoginEvent m where
+  -- | Represents valid finite states at the endpoints of a 'LoginEvent'
+  type State m :: Type -> Type
+
+
+  initial :: m (State m RatifyCredentials)
+  ratifyCreds :: State m RatifyCredentials -> m (AfterCredentials (State m))
+  ratifyArtifactDir :: State m RatifyArtifactDir -> m (AfterArtifactDir (State m))
+  mkArtifactDir :: State m MkArtifactDir -> m (AfterMkArtifactDir (State m))
+  loadSession :: State m LoadLastSession -> m (AfterLoadLastSession (State m))
+  validateSession :: State m HasSavedSession -> m (AfterValidateSession (State m))
+  srpInit :: State m ReadyToAuth -> m (State m SrpInitDone)
+  srpComplete :: State m SrpInitDone -> m (AfterSrpComplete (State m))
+  acctLogin :: State m DoAccountLogin -> m (AfterAcctLogin (State m))
+  listTwoSaDevices :: State m NeedsTwoSa -> m (State m TwoSaReady)
+  beginTwoFa :: State m ReadyForTwoFa -> TwoFaConfig -> m (State m TwoFaVerifying)
+  verifyTwoFa :: State m TwoFaVerifying -> TwoFaConfig -> m (AfterTwoFaVerify (State m))
+  doTrust :: State m DoTrust -> m (State m DoAccountLogin)
+  beginTwoSa :: State m ReadyForTwoSa -> TwoSaConfig -> m (State m TwoSaVerifying)
+  verifyTwoSa :: State m TwoSaVerifying -> TwoSaConfig -> m (AfterTwoSaVerify (State m))
+
+
+-- | The outcome of 'loginProcess'.
+data LoginOutcome f
+  = LoginAuthenticated (f AuthComplete)
+  | LoginNeedsTwoFa (f NeedsTwoFa)
+  | LoginNeedsTwoSa (f TwoSaReady)
+  | LoginHaltCreds (f HaltMissingCredentials)
+  | LoginHaltDir (f HaltCannotMkArtifactDir)
+  | LoginHaltSrp (f HaltInvalidSrp)
+  | LoginHaltTwoFaLocked (f HaltTwoFaLocked)
+
+
+-- | The outcome of 'twoFaProcess' and 'twoSaProcess'.
+data CompletionOutcome f
+  = CompletionAuthenticated (f AuthComplete)
+  | CompletionNeedsTwoFa (f NeedsTwoFa)
+  | CompletionNeedsTwoSa (f TwoSaReady)
+  | CompletionTwoFaLocked (f HaltTwoFaLocked)
+
+
+-- | The canonical login process using events from 'LoginEvent'.
+loginProcess
+  :: ( LoginEvent m
+     , Monad m
+     )
+  => m (LoginOutcome (State m))
+loginProcess =
+  initial >>= ratifyCreds >>= \case
+    NoCreds e -> pure $ LoginHaltCreds e
+    GotCreds x -> onCredsLoaded x
+
+
+onCredsLoaded
+  :: (Monad m, LoginEvent m)
+  => State m RatifyArtifactDir
+  -> m (LoginOutcome (State m))
+onCredsLoaded s =
+  ratifyArtifactDir s >>= \case
+    DirPresent x -> onArtifactDirPresent x
+    DirAbsent a ->
+      mkArtifactDir a >>= \case
+        NotMade e -> pure $ LoginHaltDir e
+        DirMade x -> onArtifactDirPresent x
+
+
+onArtifactDirPresent
+  :: (Monad m, LoginEvent m)
+  => State m LoadLastSession
+  -> m (LoginOutcome (State m))
+onArtifactDirPresent s =
+  loadSession s >>= \case
+    HasClientId x -> onReadyToAuth x
+    HasPriorSession x ->
+      validateSession x >>= \case
+        SessionStillValid y -> pure $ LoginAuthenticated y
+        SessionStale y -> onReadyToAuth y
+
+
+onReadyToAuth
+  :: (Monad m, LoginEvent m)
+  => State m ReadyToAuth
+  -> m (LoginOutcome (State m))
+onReadyToAuth s =
+  srpInit s >>= srpComplete >>= \case
+    SrpCompleteOk x -> acctLogin x >>= fmap completionToLogin . onAcctLoginDone
+    SrpCompleteInvalidKey x -> pure $ LoginHaltSrp x
+
+
+onAcctLoginDone
+  :: (Monad m, LoginEvent m)
+  => AfterAcctLogin (State m)
+  -> m (CompletionOutcome (State m))
+onAcctLoginDone = \case
+  AcctLoginOk y -> pure $ CompletionAuthenticated y
+  AcctLogin2FA y -> pure $ CompletionNeedsTwoFa y
+  AcctLogin2SA y -> listTwoSaDevices y <&> CompletionNeedsTwoSa
+
+
+completionToLogin :: CompletionOutcome f -> LoginOutcome f
+completionToLogin (CompletionAuthenticated x) = LoginAuthenticated x
+completionToLogin (CompletionNeedsTwoFa x) = LoginNeedsTwoFa x
+completionToLogin (CompletionNeedsTwoSa x) = LoginNeedsTwoSa x
+completionToLogin (CompletionTwoFaLocked x) = LoginHaltTwoFaLocked x
+
+
+-- | The 2FA completion process using events from 'LoginEvent'.
+twoFaProcess
+  :: (LoginEvent m, Monad m)
+  => State m ReadyForTwoFa
+  -> TwoFaConfig
+  -> m (CompletionOutcome (State m))
+twoFaProcess s cfg =
+  beginTwoFa s cfg >>= flip verifyTwoFa cfg >>= \case
+    TwoFaOk x -> doTrust x >>= acctLogin >>= onAcctLoginDone
+    TwoFaRetry x -> twoFaProcess x cfg
+    TwoFaLocked x -> pure $ CompletionTwoFaLocked x
+
+
+-- | The 2SA completion process using events from 'LoginEvent'.
+twoSaProcess
+  :: (LoginEvent m, Monad m)
+  => State m ReadyForTwoSa
+  -> TwoSaConfig
+  -> m (CompletionOutcome (State m))
+twoSaProcess s cfg =
+  beginTwoSa s cfg >>= flip verifyTwoSa cfg >>= \case
+    TwoSaOk x -> acctLogin x >>= onAcctLoginDone
+    TwoSaRetry x -> twoSaProcess x cfg
+
+
+{- | The states of FSM defining the login process.
+
+Each constructor specifies the concrete data required by the process in that
+state, and is tagged with a distinct phantom type.
+-}
+data LoginFSM s where
+  RatifyCredentials :: LoginFSM RatifyCredentials
+  HaltMissingCredentials :: LoginFSM HaltMissingCredentials
+  RatifyArtifactDir :: Credentials -> LoginFSM RatifyArtifactDir
+  MkArtifactDir :: Credentials -> LoginFSM MkArtifactDir
+  HaltCannotMkArtifactDir :: Credentials -> LoginFSM HaltCannotMkArtifactDir
+  LoadLastSession :: Credentials -> LoginFSM LoadLastSession
+  HasSavedSession :: Credentials -> SavedHeaders -> LoginFSM HasSavedSession
+  ReadyToAuth :: Credentials -> SavedHeaders -> LoginFSM ReadyToAuth
+  SrpInitDone :: Credentials -> SrpContext -> LoginFSM SrpInitDone
+  DoAccountLogin :: Credentials -> LoginFSM DoAccountLogin
+  AuthComplete :: Credentials -> AccountData -> LoginFSM AuthComplete
+  NeedsTwoFa :: Credentials -> LoginFSM NeedsTwoFa
+  ReadyForTwoFa :: Credentials -> TrustData -> LoginFSM ReadyForTwoFa
+  TwoFaVerifying :: Credentials -> TrustData -> Maybe TrustedPhone -> LoginFSM TwoFaVerifying
+  DoTrust :: Credentials -> LoginFSM DoTrust
+  NeedsTwoSa :: Credentials -> LoginFSM NeedsTwoSa
+  TwoSaReady :: Credentials -> NonEmpty Setup2SADevice -> LoginFSM TwoSaReady
+  ReadyForTwoSa :: Credentials -> NonEmpty Setup2SADevice -> LoginFSM ReadyForTwoSa
+  TwoSaVerifying :: Credentials -> Setup2SADevice -> NonEmpty Setup2SADevice -> LoginFSM TwoSaVerifying
+  HaltInvalidSrp :: Credentials -> LoginFSM HaltInvalidSrp
+  HaltTwoFaLocked :: Credentials -> LoginFSM HaltTwoFaLocked
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data RatifyCredentials
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data HaltMissingCredentials
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data RatifyArtifactDir
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data MkArtifactDir
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data HaltCannotMkArtifactDir
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data LoadLastSession
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data HasSavedSession
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data ReadyToAuth
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data SrpInitDone
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data DoAccountLogin
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data AuthComplete
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data NeedsTwoFa
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data ReadyForTwoFa
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data TwoFaVerifying
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data DoTrust
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data NeedsTwoSa
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data TwoSaReady
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data ReadyForTwoSa
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data TwoSaVerifying
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data HaltInvalidSrp
+
+
+-- | Phantom type linked to a unique state in 'LoginFSM'
+data HaltTwoFaLocked
+
+
+-- | The valid states after 'loadSession'
+data AfterLoadLastSession f
+  = HasClientId (f ReadyToAuth)
+  | HasPriorSession (f HasSavedSession)
+
+
+-- | The valid states after 'validateSession'
+data AfterValidateSession f
+  = SessionStillValid (f AuthComplete)
+  | SessionStale (f ReadyToAuth)
+
+
+-- | The valid states after 'mkArtifactDir'
+data AfterMkArtifactDir f
+  = NotMade (f HaltCannotMkArtifactDir)
+  | DirMade (f LoadLastSession)
+
+
+-- | The valid states after 'ratifyArtifactDir'
+data AfterArtifactDir f
+  = DirPresent (f LoadLastSession)
+  | DirAbsent (f MkArtifactDir)
+
+
+-- | The valid states after 'ratifyCreds'
+data AfterCredentials f
+  = NoCreds (f HaltMissingCredentials)
+  | GotCreds (f RatifyArtifactDir)
+
+
+-- | The valid states after 'srpComplete'
+data AfterSrpComplete f
+  = SrpCompleteOk (f DoAccountLogin)
+  | SrpCompleteInvalidKey (f HaltInvalidSrp)
+
+
+-- | The valid states after 'acctLogin'
+data AfterAcctLogin f
+  = AcctLoginOk (f AuthComplete)
+  | AcctLogin2FA (f NeedsTwoFa)
+  | AcctLogin2SA (f NeedsTwoSa)
+
+
+-- | The valid states after 'verifyTwoFa'
+data AfterTwoFaVerify f
+  = TwoFaOk (f DoTrust)
+  | TwoFaRetry (f ReadyForTwoFa)
+  | TwoFaLocked (f HaltTwoFaLocked)
+
+
+-- | The valid states after 'verifyTwoSa'
+data AfterTwoSaVerify f
+  = TwoSaOk (f DoAccountLogin)
+  | TwoSaRetry (f ReadyForTwoSa)
diff --git a/src-internal/Network/HStratus/Internal/PBKDF2.hs b/src-internal/Network/HStratus/Internal/PBKDF2.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/PBKDF2.hs
@@ -0,0 +1,166 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE BinaryLiterals #-}
+{-# OPTIONS_HADDOCK prune #-}
+
+{- |
+Module      : Network.HStratus.PBKDF2
+Copyright   : (c) 2025 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+
+Copied then modified from an implementation in the package
+[ppad-pbkdf](https://git.ppad.tech/pbkdf/file/lib/Crypto/KDF/PBKDF.hs.html)
+
+Re-implemented here rather than making it direct dependency, because:
+    - 1 fewer dependency => less future dependency-related maintenance
+    - faster route for this package to stackage
+        - as of (2025/04/01, ppad-ppbkdf was not on stackage)
+-}
+module Network.HStratus.Internal.PBKDF2
+  ( -- * specify a pseudorandom function and derived key length
+    FancyPseudoRandomF
+  , wrap
+  , wrapIO
+  , PseudoRandomF
+  , BadKeyLength (..)
+
+    -- * perform PBKDF2 derivation
+  , deriveKey
+
+    -- * re-export
+  , ByteString
+  )
+where
+
+import Control.Exception (Exception, throwIO)
+import Data.Bits (shiftR, xor, (.&.))
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import Data.ByteString.Builder (byteString, toLazyByteString)
+import Data.ByteString.Builder.Extra
+  ( safeStrategy
+  , smallChunkSize
+  , toLazyByteStringWith
+  )
+import Data.Word (Word32, Word64)
+
+
+{- | A pseudorandom function for use in PBKDF2
+
+See
+[PBKDF-RFC/section5.2](https://datatracker.ietf.org/doc/html/rfc2898#section-5.2)
+-}
+type PseudoRandomF = ByteString -> ByteString -> ByteString
+
+
+-- | Indicates the derived key length is too long
+data BadKeyLength = TooLong
+  deriving (Eq, Show)
+
+
+instance Exception BadKeyLength
+
+
+{- | A 'PseudoRandomF' wrapped up with @dkLen@ and @hLen@'
+
+where @dkLen@ the required length in octets of the derived key
+and @hLen@ is the length of the output of the 'PseudoRandomF'
+
+As per
+[PBKDF-RFC/section5.2](https://datatracker.ietf.org/doc/html/rfc2898#section-5.2)
+
+@dkLen@ must be at most 2^32 - 1 * @hLen@
+
+The constructor `wrap` enforces this constraint
+-}
+newtype FancyPseudoRandomF = Fancy (PseudoRandomF, Word32, Word32)
+
+
+-- | Construct a 'FancyPseudoRandomF'
+wrap :: PseudoRandomF -> Word32 -> Either BadKeyLength FancyPseudoRandomF
+wrap f dkLen =
+  let !hLen = toNum $ BS.length $ f mempty mempty
+   in if dkLen > 0xffffffff * hLen
+        then Left TooLong
+        else Right $ Fancy (f, dkLen, hLen)
+
+
+-- | Like 'wrap', but fails by throwing 'BadKeyLength' in IO
+wrapIO :: PseudoRandomF -> Word32 -> IO FancyPseudoRandomF
+wrapIO f = either throwIO pure . wrap f
+
+
+blockInfoOf :: FancyPseudoRandomF -> (Word32, Int)
+blockInfoOf (Fancy (_f, !dkLen, hLen)) =
+  let numBlocks = ceiling (toNum dkLen / toNum hLen :: Double)
+      lastBlockSize = toNum $ dkLen - (numBlocks - 1) * hLen
+   in (numBlocks, lastBlockSize)
+
+
+{- | Derive a key from a secret using PBKDF2
+
+Implements the key derivation algorithm described in
+[PBKDF-RFC](https://datatracker.ietf.org/doc/html/rfc2898)
+
+Usage - this example uses the SHA256 hmac function as the pseudorandom function
+
+  >>> :set -XOverloadedStrings
+  >>> import qualified Crypto.Hash.SHA256 as SHA256
+  >>> pseudoF <- wrapIO SHA256.hmac 64
+  >>> deriveKey pseudoF "passwd" "salt" 1000
+-}
+deriveKey
+  :: FancyPseudoRandomF
+  -- ^ a 'FancyPseudoRandomF'
+  -> ByteString
+  -- ^ the password from which to derive a key
+  -> ByteString
+  -- ^ the salt used in key derivation
+  -> Word64
+  -- ^ the iteration count
+  -> ByteString
+deriveKey fancy password salt count =
+  let Fancy (!pseudoRandomF, !dkLen, !_notUsed) = fancy
+      (!numBlocks, !lastBlockSize) = blockInfoOf fancy
+      xorSum i =
+        let initial = pseudoRandomF password $ salt <> asBytes i
+            go j !current !_ignored | j == count = current
+            go j !current !previous =
+              let latest = pseudoRandomF password previous
+               in go (j + 1) (current `xorBytes` latest) latest
+         in go 1 initial initial
+      {-# INLINE xorSum #-}
+
+      smaller = safeStrategy 128 smallChunkSize
+      strictBS =
+        if dkLen <= 128
+          then BS.toStrict . toLazyByteStringWith smaller mempty
+          else BS.toStrict . toLazyByteString
+      {-# INLINE strictBS #-}
+
+      genBlocks i acc =
+        if i < numBlocks
+          then genBlocks (i + 1) (acc <> byteString (xorSum i))
+          else strictBS $ acc <> byteString (BS.take lastBlockSize $ xorSum i)
+   in genBlocks 1 mempty
+
+
+toNum :: (Integral a, Num b) => a -> b
+toNum = fromIntegral
+{-# INLINE toNum #-}
+
+
+asBytes :: Word32 -> ByteString
+asBytes x =
+  let !mask = 0b00000000000000000000000011111111
+      !word0 = toNum (x `shiftR` 24) .&. mask
+      !word1 = toNum (x `shiftR` 16) .&. mask
+      !word2 = toNum (x `shiftR` 08) .&. mask
+      !word3 = toNum x .&. mask
+   in BS.cons word0 $ BS.cons word1 $ BS.cons word2 $ BS.singleton word3
+{-# INLINE asBytes #-}
+
+
+xorBytes :: ByteString -> ByteString -> ByteString
+xorBytes = BS.packZipWith xor
+{-# INLINE xorBytes #-}
diff --git a/src-internal/Network/HStratus/Internal/Session.hs b/src-internal/Network/HStratus/Internal/Session.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Session.hs
@@ -0,0 +1,572 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TupleSections #-}
+{-# OPTIONS_HADDOCK prune #-}
+
+module Network.HStratus.Internal.Session
+  ( -- * Credentials
+    Credentials (..)
+
+    -- ** paths related to @Credentials@
+  , cookiePath
+  , clientIdPath
+  , credentialsPath
+  , savedHeadersPath
+  , loginMsgPath
+
+    -- * Session
+  , Session (..)
+  , SavedHeaders (..)
+  , loadSession
+  , saveCredentials
+  , saveCredentialsTo
+  , loadSavedHeaders
+  , updateSessionSavedHeaders
+  , updateSavedHeaders
+  , pristine
+  , saveLoginMsg
+
+    -- * AccountData
+  , Webservice (..)
+  , AccountData (..)
+  , accountDataRequires2FA
+  , accountDataRequires2SA
+  , unknownAccountData
+  , accountDataPath
+  , saveAccountData
+  , loadAccountData
+
+    -- * path components
+  , appBase
+  , (</>)
+
+    -- * Utilities
+  , encodeFileAtomic
+
+    -- * File security
+  , checkSecureMode
+  , requireSecureFile
+  , checkSessionFiles
+  )
+where
+
+import Control.Applicative ((<|>))
+import Control.Exception (bracketOnError, throwIO)
+import Control.Monad (forM, when, (>=>))
+import Data.Aeson
+  ( FromJSON (..)
+  , KeyValue (..)
+  , Options (..)
+  , ToJSON (..)
+  , Value
+  , eitherDecodeFileStrict
+  , encode
+  , genericParseJSON
+  , genericToEncoding
+  , genericToJSON
+  , object
+  , withObject
+  , (.:)
+  , (.:?)
+  )
+import Data.Aeson.Casing (aesonPrefix, snakeCase)
+import qualified Data.Aeson.Key as AesonKey
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Bits ((.&.))
+import qualified Data.ByteString.Lazy as LBS
+import Data.Char (isAlphaNum)
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (catMaybes)
+import Data.String.Conv (toS)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.IO as Text
+import Data.UUID (toText)
+import Data.UUID.V4 (nextRandom)
+import GHC.Generics (Generic)
+import Network.HStratus.Internal.Http
+  ( hCounter
+  , hCountry
+  , hSessionId
+  , hSessionToken
+  , hTrustToken
+  )
+import Network.HTTP.Types.Header (Header)
+import Numeric (showOct)
+import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile, renameFile)
+import System.Environment.XDG.BaseDir (getUserConfigDir)
+import System.FilePath (takeDirectory, (</>))
+import System.IO (hClose, openTempFile)
+import System.Posix.Files (fileMode, getFileStatus, setFileMode)
+import System.Posix.Types (FileMode)
+
+
+-- | Update the @SavedHeaders@ using some response headers
+updateSavedHeaders :: [Header] -> SavedHeaders -> SavedHeaders
+updateSavedHeaders hs sd =
+  sd
+    { shCountry = (toS <$> lookup hCountry hs) <|> shCountry sd
+    , shSessionId = (toS <$> lookup hSessionId hs) <|> shSessionId sd
+    , shSessionToken = (toS <$> lookup hSessionToken hs) <|> shSessionToken sd
+    , shTrustToken = (toS <$> lookup hTrustToken hs) <|> shTrustToken sd
+    , shCounter = (toS <$> lookup hCounter hs) <|> shCounter sd
+    }
+
+
+data Session = Session
+  { sessionCreds :: !Credentials
+  -- ^ the credentials used to authenticate
+  , sessionTopDir :: !FilePath
+  -- ^ directory where session files (cookies, headers, account data) are stored
+  , sessionClientId :: !Text
+  -- ^ per-client OAuth state identifier sent with each request
+  }
+  deriving
+    ( Eq
+      -- ^ don't derive Show to avoid the risk of logging a password
+    )
+
+
+-- | Generates a new client ID.
+newClientId :: IO Text
+newClientId = ("auth-" <>) . toText <$> nextRandom
+
+
+{- | Determine the path of file containing the HTTP response headers to be
+preserved to maintain a user's authentication state
+-}
+savedHeadersPath :: FilePath -> Credentials -> FilePath
+savedHeadersPath topDir creds = topDir </> Text.unpack (sessionBase creds)
+
+
+-- | Determine the Cookie Jar file for user with the given credentials
+cookiePath :: FilePath -> Credentials -> FilePath
+cookiePath topDir creds = topDir </> Text.unpack (cookieBase creds)
+
+
+{- | Determine the path of file containing the client ID for user with the given
+credentials
+-}
+clientIdPath :: FilePath -> Credentials -> FilePath
+clientIdPath topDir creds = topDir </> Text.unpack (clientIdBase creds)
+
+
+-- | Determine the path of file to save the message the api returns on logon
+loginMsgPath :: FilePath -> Credentials -> FilePath
+loginMsgPath topDir creds = topDir </> Text.unpack (loginMsgBase creds)
+
+
+-- | Save the login message to user specific filepath
+saveLoginMsg :: Session -> Value -> IO ()
+saveLoginMsg Session{sessionCreds = creds, sessionTopDir = topDir} = saveValue (loginMsgPath topDir creds)
+
+
+-- | Metadata for a single iCloud webservice entry.
+data Webservice = Webservice
+  { wsUrl :: !Text
+  -- ^ the base URL for the service
+  , wsStatus :: !(Maybe Text)
+  -- ^ service status, e.g. @"active"@ or @"inactive"@; @Nothing@ if absent
+  }
+  deriving (Eq, Show)
+
+
+data AccountData = AccountData
+  { adHsaVersion :: !Int
+  -- ^ HSA protocol version; drives the two-factor flow selection
+  , adHsaChallengeRequired :: !Bool
+  -- ^ @True@ when a 2FA challenge must be completed before access is granted
+  , adHsaTrustedBrowser :: !(Maybe Bool)
+  {- ^ @Just True@ when this session is already trusted; @Just False@ when explicitly
+  untrusted; @Nothing@ when the key was absent from Apple's response (treated as trusted)
+  -}
+  , adWebservices :: !(Map Text Webservice)
+  -- ^ map of webservice name to service info; use 'lookupWebservice' to resolve a URL
+  , adRaw :: !Value
+  -- ^ the original JSON value from Apple; preserved so serialisation round-trips losslessly
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON AccountData where
+  parseJSON v = withObject "AccountData" go v
+   where
+    go o = do
+      dsInfo <- o .: "dsInfo"
+      adHsaVersion <- withObject "dsInfo" (.: "hsaVersion") dsInfo
+      adHsaChallengeRequired <- o .:? "hsaChallengeRequired" >>= maybe (pure False) pure
+      adHsaTrustedBrowser <- o .:? "hsaTrustedBrowser"
+      adWebservices <- do
+        mbWs <- o .:? "webservices"
+        maybe (pure Map.empty) (withObject "webservices" parseWebservices) mbWs
+      pure AccountData{adHsaVersion, adHsaChallengeRequired, adHsaTrustedBrowser, adWebservices, adRaw = v}
+    parseWebservices obj = do
+      let pairs = KeyMap.toAscList obj
+      wsPairs <- forM pairs $ \(k, wsVal) ->
+        withObject
+          "webservice"
+          ( \sv -> do
+              mbUrl <- sv .:? "url"
+              mbStatus <- sv .:? "status"
+              pure $ fmap (\u -> (AesonKey.toText k, Webservice u mbStatus)) mbUrl
+          )
+          wsVal
+      pure $ Map.fromList $ catMaybes wsPairs
+
+
+instance ToJSON AccountData where
+  toJSON AccountData{adRaw} = adRaw
+  toEncoding AccountData{adRaw} = toEncoding adRaw
+
+
+-- | True when full 2FA (auth-endpoint) challenge is required
+accountDataRequires2FA :: AccountData -> Bool
+accountDataRequires2FA ad =
+  adHsaVersion ad == 2
+    && (adHsaChallengeRequired ad || adHsaTrustedBrowser ad == Just False)
+
+
+-- | True when legacy 2SA (setup-endpoint) challenge is required
+accountDataRequires2SA :: AccountData -> Bool
+accountDataRequires2SA ad = adHsaVersion ad == 1
+
+
+-- | Sentinel used when no saved @AccountData@ is available
+unknownAccountData :: AccountData
+unknownAccountData =
+  AccountData
+    { adHsaVersion = 0
+    , adHsaChallengeRequired = False
+    , adHsaTrustedBrowser = Nothing
+    , adWebservices = Map.empty
+    , adRaw = object []
+    }
+
+
+accountDataBase :: Credentials -> Text
+accountDataBase = (<> ".account-data.json") . sprucedName
+
+
+-- | Determine the path of the saved account-data file for the given credentials
+accountDataPath :: FilePath -> Credentials -> FilePath
+accountDataPath topDir creds = topDir </> Text.unpack (accountDataBase creds)
+
+
+-- | Persist @AccountData@ to the session's filesystem location
+saveAccountData :: Session -> AccountData -> IO ()
+saveAccountData Session{sessionCreds = creds, sessionTopDir = topDir} =
+  secureEncodeFileAtomic (accountDataPath topDir creds)
+
+
+-- | Load persisted @AccountData@; returns @Nothing@ if the file is absent
+loadAccountData :: Session -> IO (Maybe AccountData)
+loadAccountData Session{sessionCreds = creds, sessionTopDir = topDir} = do
+  let path = accountDataPath topDir creds
+  requireSecureFile path
+  exists <- doesFileExist path
+  if not exists
+    then pure Nothing
+    else eitherDecodeFileStrict path >>= either (const (pure Nothing)) (pure . Just)
+
+
+{- | Determine the path of file containing the credentials in the configuration
+   directory
+-}
+credentialsPath :: FilePath -> FilePath
+credentialsPath topDir = topDir </> "credentials.json"
+
+
+saveCredentials :: Credentials -> IO ()
+saveCredentials creds = getUserConfigDir appBase >>= (`saveCredentialsTo` creds)
+
+
+-- | Write 'Credentials' to @credentials.json@ inside @topDir@, creating @topDir@ if absent.
+saveCredentialsTo :: FilePath -> Credentials -> IO ()
+saveCredentialsTo topDir creds = do
+  createDirectoryIfMissing True topDir
+  secureEncodeFileAtomic (credentialsPath topDir) creds
+
+
+data Credentials = Credentials
+  { credAccountName :: !Text
+  -- ^ the account ID; typically an email address
+  , credPassword :: !Text
+  -- ^ the iCloud account password
+  }
+  deriving
+    ( Eq
+      -- ^ don't derive Show to avoid the risk of logging a password
+    )
+
+
+instance FromJSON Credentials where
+  parseJSON = withObject "Credentials" $ \o ->
+    let accountName = o .: "accountName"
+        password = o .: "password"
+     in Credentials <$> accountName <*> password
+
+
+instance ToJSON Credentials where
+  toJSON c =
+    object
+      [ "password" .= credPassword c
+      , "accountName" .= credAccountName c
+      ]
+
+
+sprucedName :: Credentials -> Text
+sprucedName =
+  let p aChar = isAlphaNum aChar || aChar == '@'
+      replaceAt = Text.replace "@" "-"
+   in replaceAt . Text.filter p . credAccountName
+
+
+cookieBase :: Credentials -> Text
+cookieBase = (<> ".cookies.txt") . sprucedName
+
+
+sessionBase :: Credentials -> Text
+sessionBase = (<> ".session.json") . sprucedName
+
+
+clientIdBase :: Credentials -> Text
+clientIdBase = (<> ".client-id.txt") . sprucedName
+
+
+loginMsgBase :: Credentials -> Text
+loginMsgBase = (<> ".last-logon.json") . sprucedName
+
+
+-- | Data obtained from HTTP response headers that define a user session
+data SavedHeaders = SavedHeaders
+  { shCountry :: !(Maybe Text)
+  -- ^ X-Apple-ID-Country value from the last response
+  , shSessionId :: !(Maybe Text)
+  -- ^ X-Apple-ID-Session-Id value from the last response
+  , shSessionToken :: !(Maybe Text)
+  -- ^ X-Apple-Session-Token value from the last response
+  , shTrustToken :: !(Maybe Text)
+  -- ^ X-Apple-TwoSV-Trust-Token value from the last response
+  , shCounter :: !(Maybe Text)
+  -- ^ X-Apple-HC-Bits value from the last response; used to derive hashcash proofs
+  }
+  deriving (Eq, Show, Generic)
+
+
+instance FromJSON SavedHeaders where
+  parseJSON = genericParseJSON simpleOptions
+
+
+instance ToJSON SavedHeaders where
+  toJSON = genericToJSON simpleOptions
+  toEncoding = genericToEncoding simpleOptions
+
+
+-- | A @SavedHeaders@ with nothing set
+pristine :: SavedHeaders
+pristine = SavedHeaders Nothing Nothing Nothing Nothing Nothing
+
+
+{- | Update the stored saved headers
+
+if the sessionData file exists
+then
+  load it.
+  update the session data from the headers
+  save the updated data
+else
+  ensure its parent directory exists
+  create the session data from the headers
+  save it
+
+not handled (thrown as IOException):
+  cannot create directory
+  cannot write due to permissions
+  file exists, but data cannot be parsed
+-}
+updateSessionSavedHeaders
+  :: Session
+  -> (SavedHeaders -> SavedHeaders)
+  -- ^ a function that modifies the session's saved headers
+  -> IO ()
+updateSessionSavedHeaders s modSavedHeaders = do
+  let dataPath = savedHeadersPath (sessionTopDir s) (sessionCreds s)
+      updateAndSave = secureEncodeFileAtomic dataPath . modSavedHeaders
+      loadLast False = pure pristine
+      loadLast True = eitherDecodeFileStrict dataPath >>= either (fail . show) pure
+
+  doesFileExist dataPath >>= loadLast >>= updateAndSave
+
+
+loadSession :: IO Session
+loadSession = do
+  sessionTopDir <- getUserConfigDir appBase
+  createDirectoryIfMissing True sessionTopDir
+  s <-
+    loadSessionOr sessionTopDir
+      >>= orFail "Credentials are missing or corrupt; run 'hstratus auth login' to authenticate"
+  checkSessionFiles s
+  pure s
+
+
+-- | Saves a JSON @Value@ to @filepath@
+saveValue :: FilePath -> Value -> IO ()
+saveValue fp v = LBS.writeFile fp $ encode v
+
+
+orFail :: String -> Either String a -> IO a
+orFail hint = either (\e -> fail (hint <> " (" <> e <> ")")) pure
+
+
+-- | Write a JSON-encodable value to @path@ atomically via a temp file and rename.
+encodeFileAtomic :: (ToJSON a) => FilePath -> a -> IO ()
+encodeFileAtomic path value =
+  bracketOnError
+    (openTempFile (takeDirectory path) ".tmp")
+    (\(tmpPath, h) -> hClose h >> removeFile tmpPath)
+    ( \(tmpPath, h) -> do
+        LBS.hPut h (encode value)
+        hClose h
+        renameFile tmpPath path
+    )
+
+
+{- | Like 'encodeFileAtomic' but sets mode @0o600@ on the temp file before
+renaming, so the file is never visible at a more permissive mode.
+-}
+secureEncodeFileAtomic :: (ToJSON a) => FilePath -> a -> IO ()
+secureEncodeFileAtomic path value =
+  bracketOnError
+    (openTempFile (takeDirectory path) ".tmp")
+    (\(tmpPath, h) -> hClose h >> removeFile tmpPath)
+    ( \(tmpPath, h) -> do
+        LBS.hPut h (encode value)
+        hClose h
+        setFileMode tmpPath 0o600
+        renameFile tmpPath path
+    )
+
+
+-- | Write text to @path@ atomically, setting mode @0o600@ before renaming.
+secureWriteTextFileAtomic :: FilePath -> Text -> IO ()
+secureWriteTextFileAtomic path content =
+  bracketOnError
+    (openTempFile (takeDirectory path) ".tmp")
+    (\(tmpPath, h) -> hClose h >> removeFile tmpPath)
+    ( \(tmpPath, h) -> do
+        Text.hPutStr h content
+        hClose h
+        setFileMode tmpPath 0o600
+        renameFile tmpPath path
+    )
+
+
+{- | Pure permission check using the SSH convention: no group or world bits may
+be set.  Returns @Left@ with a descriptive message (including a @chmod 600@ hint)
+when the mode is too permissive.
+-}
+checkSecureMode :: FileMode -> FilePath -> Either String ()
+checkSecureMode mode path
+  | mode .&. 0o077 /= 0 =
+      Left $
+        path
+          <> " has unsafe permissions ("
+          <> showOct (fromIntegral mode :: Int) ""
+          <> "); fix with: chmod 600 "
+          <> path
+  | otherwise = Right ()
+
+
+{- | Verify that a file has secure permissions before it is read.  Does nothing
+if the file does not exist; absence is handled by the caller.  Throws an
+'IOError' when the file exists but its mode has group or world bits set.
+-}
+requireSecureFile :: FilePath -> IO ()
+requireSecureFile path = do
+  exists <- doesFileExist path
+  when exists $ do
+    mode <- fileMode <$> getFileStatus path
+    either (throwIO . userError) pure (checkSecureMode mode path)
+
+
+{- | Check that every session file that exists has secure permissions.  Absent
+files are skipped silently.  Throws an 'IOError' for the first file found with
+group or world bits set.
+
+Covers all five session paths: credentials, saved headers, client ID, account
+data, and the cookie jar.
+-}
+checkSessionFiles :: Session -> IO ()
+checkSessionFiles sess = do
+  let topDir = sessionTopDir sess
+      creds = sessionCreds sess
+  mapM_
+    requireSecureFile
+    [ credentialsPath topDir
+    , savedHeadersPath topDir creds
+    , clientIdPath topDir creds
+    , accountDataPath topDir creds
+    , cookiePath topDir creds
+    ]
+
+
+loadCredentials :: FilePath -> IO (Either String Credentials)
+loadCredentials topDir = do
+  let path = credentialsPath topDir
+  requireSecureFile path
+  eitherDecodeFileStrict path
+
+
+loadCredentials' :: FilePath -> IO (Either String (FilePath, Credentials))
+loadCredentials' topDir = fmap (topDir,) <$> loadCredentials topDir
+
+
+loadSession' :: Either String (FilePath, Credentials) -> IO (Either String Session)
+loadSession' (Left err) = pure $ Left err
+loadSession' (Right (sessionTopDir, sessionCreds)) = do
+  sessionClientId <- loadClientId sessionTopDir sessionCreds
+  pure $ Right Session{sessionClientId, sessionCreds, sessionTopDir}
+
+
+loadSessionOr :: FilePath -> IO (Either String Session)
+loadSessionOr = loadCredentials' >=> loadSession'
+
+
+-- | Load the @SavedHeaders@ for this session
+loadSavedHeaders :: Session -> IO SavedHeaders
+loadSavedHeaders Session{sessionTopDir, sessionCreds} =
+  loadSavedHeaders' sessionTopDir sessionCreds
+    >>= orFail "Session state is corrupt; run 'hstratus auth login' to re-authenticate"
+
+
+loadSavedHeaders' :: FilePath -> Credentials -> IO (Either String SavedHeaders)
+loadSavedHeaders' topDir creds = do
+  let dataPath = savedHeadersPath topDir creds
+  requireSecureFile dataPath
+  pathExists <- doesFileExist dataPath
+  if not pathExists
+    then pure $ Right pristine
+    else eitherDecodeFileStrict dataPath
+
+
+loadClientId :: FilePath -> Credentials -> IO Text
+loadClientId topDir creds = do
+  let dataPath = clientIdPath topDir creds
+  requireSecureFile dataPath
+  pathExists <- doesFileExist dataPath
+  if pathExists
+    then Text.readFile dataPath
+    else do
+      anId <- newClientId
+      secureWriteTextFileAtomic dataPath anId
+      pure anId
+
+
+simpleOptions :: Options
+simpleOptions = aesonPrefix snakeCase
+
+
+appBase :: FilePath
+appBase = "hstratus"
diff --git a/src-internal/Network/HStratus/Internal/Trust.hs b/src-internal/Network/HStratus/Internal/Trust.hs
new file mode 100644
--- /dev/null
+++ b/src-internal/Network/HStratus/Internal/Trust.hs
@@ -0,0 +1,336 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_HADDOCK prune #-}
+
+{- |
+Module      : Network.HStratus.Internal.Trust
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Internal types and functions for two-factor authentication trust management.
+-}
+module Network.HStratus.Internal.Trust
+  ( -- * data types
+    CodeStatus (..)
+  , TrustedPhone (..)
+  , TrustedDevice (..)
+  , TrustedList (..)
+  , TrustData (..)
+  , Setup2SADevice (..)
+
+    -- * functions
+  , withSelectedPhoneOrDevice
+  , pleaseReadCode
+  , pleaseChooseN
+  , selectPhone
+  , selectDevice
+  , selectTwoFaPhone
+  , setup2SADeviceLabel
+  , selectSetupDevice
+  )
+where
+
+import Control.Applicative ((<|>))
+import Control.Exception (IOException, catch, throwIO)
+import Data.Aeson
+  ( FromJSON (..)
+  , KeyValue (..)
+  , Object
+  , Options (..)
+  , SumEncoding (ObjectWithSingleField)
+  , ToJSON (..)
+  , Value (..)
+  , genericParseJSON
+  , genericToEncoding
+  , genericToJSON
+  , object
+  , withObject
+  , (.:)
+  , (.:?)
+  )
+import Data.Aeson.Casing (aesonPrefix, camelCase)
+import Data.Aeson.KeyMap (filterWithKey)
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Aeson.Types (Parser)
+import Data.List.NonEmpty (NonEmpty (..), toList)
+import Data.Maybe (fromMaybe, listToMaybe)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.IO as Text
+import Data.Word (Word8)
+import GHC.Generics (Generic)
+import SimplePrompt (promptNonEmpty)
+import System.IO.Error (isEOFError)
+import Text.Read (readMaybe)
+
+
+putDeviceChoice :: (Int, TrustedDevice) -> IO ()
+putDeviceChoice (i, td)
+  | tdModelName td == "" =
+      Text.putStrLn $ Text.pack (show i) <> ") " <> tdName td <> "\tSMS\t" <> tdId td
+  | otherwise =
+      Text.putStrLn $ Text.pack (show i) <> ") " <> tdName td <> "\t" <> tdModelName td <> "\t" <> tdId td
+
+
+-- idx is 1-based and in [1, length xs], as enforced by pleaseChooseN
+nthOf :: NonEmpty a -> Int -> a
+nthOf xs idx = toList xs !! (idx - 1)
+
+
+-- | Prompt the user to choose one device from a non-empty list of trusted devices.
+selectDevice :: NonEmpty TrustedDevice -> IO TrustedDevice
+selectDevice xs = do
+  Text.putStrLn "Please select a trusted device to send a code to"
+  mapM_ putDeviceChoice $ zip ([1 ..] :: [Int]) (toList xs)
+  idx <- pleaseChooseN 1 (length xs)
+  pure (nthOf xs idx)
+
+
+-- | Prompt the user to choose one phone number from a non-empty list of trusted phones.
+selectPhone :: NonEmpty TrustedPhone -> IO TrustedPhone
+selectPhone xs = do
+  let putPhoneChoice (i, x) = Text.putStrLn $ Text.pack (show i) <> ") " <> tpnNumberWithDialCode x
+  Text.putStrLn "Please select a trusted phone number to send a code to"
+  mapM_ putPhoneChoice $ zip ([1 ..] :: [Int]) (toList xs)
+  idx <- pleaseChooseN 1 (length xs)
+  pure (nthOf xs idx)
+
+
+-- | Prompt the user to enter an integer in the inclusive range @[low, high]@, retrying on invalid input.
+pleaseChooseN :: Int -> Int -> IO Int
+pleaseChooseN low high = do
+  let prefix = "Please choose an option between " <> show low <> " and " <> show high
+  result <- (readMaybe <$> promptNonEmpty prefix) `catch` onEof
+  case result of
+    Nothing -> pleaseChooseN low high
+    Just x | x < low || x > high -> pleaseChooseN low high
+    Just x -> pure x
+ where
+  onEof :: IOException -> IO (Maybe Int)
+  onEof e
+    | isEOFError e = throwIO (userError "unexpected end of input")
+    | otherwise = throwIO e
+
+
+-- | Prompt the user to enter a security code of the given length.
+pleaseReadCode :: Word8 -> IO Text
+pleaseReadCode len = do
+  let prefix = "Please enter the " <> show len <> "-digit code you just received"
+  Text.pack <$> promptNonEmpty prefix
+
+
+-- | Information describing the status of the security code verification
+data CodeStatus = CodeStatus
+  { scLength :: !Word8
+  -- ^ expected number of digits in the security code
+  , scTooManyCodesSent :: !Bool
+  -- ^ @True@ when Apple has refused to send further codes
+  , scTooManyCodesValidated :: !Bool
+  -- ^ @True@ when the verification attempt limit has been reached
+  , scSecurityCodeLocked :: !Bool
+  -- ^ @True@ when the security code gate is locked
+  , scSecurityCodeCooldown :: !Bool
+  -- ^ @True@ when a cooldown period is active before a new code can be sent
+  }
+  deriving (Eq, Show, Generic)
+
+
+instance FromJSON CodeStatus where
+  parseJSON = withObject "CodeStatus" $ \o ->
+    CodeStatus
+      <$> o .: "length"
+      <*> (fromMaybe False <$> o .:? "tooManyCodesSent")
+      <*> (fromMaybe False <$> o .:? "tooManyCodesValidated")
+      <*> (fromMaybe False <$> o .:? "securityCodeLocked")
+      <*> (fromMaybe False <$> o .:? "securityCodeCooldown")
+
+
+instance ToJSON CodeStatus where
+  toJSON = genericToJSON simpleOptions
+  toEncoding = genericToEncoding simpleOptions
+
+
+-- | A trusted phone number registered for two-factor verification
+data TrustedPhone = TrustedPhone
+  { tpnId :: !Word8
+  -- ^ Apple's internal identifier for this phone number
+  , tpnNumberWithDialCode :: !Text
+  -- ^ display string including the country dial code, e.g. @"+1 (•••) •••-1234"@
+  , tpnPushMode :: !(Maybe Text)
+  -- ^ push delivery mode (e.g. @"sms"@); @Nothing@ when absent
+  }
+  deriving (Eq, Show, Generic)
+
+
+instance FromJSON TrustedPhone where
+  parseJSON = genericParseJSON simpleOptions
+
+
+instance ToJSON TrustedPhone where
+  toJSON = genericToJSON simpleOptions
+  toEncoding = genericToEncoding simpleOptions
+
+
+-- | Information about a trusted device
+data TrustedDevice = TrustedDevice
+  { tdId :: !Text
+  -- ^ Apple's internal identifier for this device
+  , tdName :: !Text
+  -- ^ human-readable device name, e.g. @"Tim's iPhone"@
+  , tdModelName :: !Text
+  -- ^ model string, e.g. @"iPhone 15 Pro"@; empty string when absent
+  }
+  deriving (Eq, Show, Generic)
+
+
+instance FromJSON TrustedDevice where
+  parseJSON = withObject "TrustedDevice" $ \o ->
+    TrustedDevice
+      <$> o .: "id"
+      <*> o .: "name"
+      <*> (fromMaybe "" <$> o .:? "modelName")
+
+
+instance ToJSON TrustedDevice where
+  toJSON = genericToJSON simpleOptions
+  toEncoding = genericToEncoding simpleOptions
+
+
+-- | A non-empty list of @TrustedPhone@ or @TrustedDevice@
+data TrustedList
+  = -- | the account has trusted phone numbers but no trusted devices
+    TrustedPhoneNumbers !(NonEmpty TrustedPhone)
+  | -- | the account has trusted devices (and may also have trusted phone numbers)
+    TrustedDevices !(NonEmpty TrustedDevice)
+  deriving (Eq, Show, Generic)
+
+
+instance FromJSON TrustedList where
+  parseJSON = genericParseJSON trustedListOptions
+
+
+instance ToJSON TrustedList where
+  toJSON = genericToJSON trustedListOptions
+  toEncoding = genericToEncoding trustedListOptions
+
+
+trustedListOptions :: Options
+trustedListOptions =
+  ( simpleOptions
+      { sumEncoding = ObjectWithSingleField
+      , constructorTagModifier = camelCase
+      }
+  )
+
+
+data TrustData = TrustData
+  { tdList :: !TrustedList
+  -- ^ trusted phones or devices that can receive a verification code
+  , tdSecurityCode :: !CodeStatus
+  -- ^ current status of the security-code gate (length, lockout flags)
+  , tdNoTrustedDevices :: !Bool
+  -- ^ @True@ when no trusted devices are registered; only phone numbers available
+  }
+  deriving (Eq, Show)
+
+
+-- | Selects a phone/device and applies the appropriate handler
+withSelectedPhoneOrDevice
+  :: (TrustedPhone -> IO a) -> (TrustedDevice -> IO a) -> TrustData -> IO a
+withSelectedPhoneOrDevice handlePhone handleDevice = do
+  let ikou (TrustedDevices ys) = selectDevice ys >>= handleDevice
+      ikou (TrustedPhoneNumbers (y :| [])) = handlePhone y
+      ikou (TrustedPhoneNumbers ys) = selectPhone ys >>= handlePhone
+  ikou . tdList
+
+
+toJSONTrustData :: TrustData -> Value
+toJSONTrustData td =
+  let asPairs (Object o) = KeyMap.toList o
+      asPairs _other = []
+      fromOthers =
+        [ "securityCode" .= tdSecurityCode td
+        , "noTrustedDevices" .= tdNoTrustedDevices td
+        ]
+      fromTrustedList = asPairs $ toJSON $ tdList td
+   in object $ fromOthers <> fromTrustedList
+
+
+parseJSONTrustData :: Value -> Parser TrustData
+parseJSONTrustData = withObject "TrustData" $ \o ->
+  let securityCode = o .: "securityCode"
+      noTrustedDevices = fromMaybe False <$> o .:? "noTrustedDevices"
+      isListKey key _ignored = key == "trustedPhoneNumbers" || key == "trustedDevices"
+      theList = parseJSON (Object $ filterWithKey isListKey o)
+   in TrustData <$> theList <*> securityCode <*> noTrustedDevices
+
+
+instance ToJSON TrustData where
+  toJSON = toJSONTrustData
+
+
+instance FromJSON TrustData where
+  parseJSON = parseJSONTrustData
+
+
+-- | An opaque device record used in the legacy 2SA flow; fields are Apple-defined JSON.
+newtype Setup2SADevice = Setup2SADevice {setup2SAFields :: Object}
+  deriving (Eq, Show)
+
+
+instance FromJSON Setup2SADevice where
+  parseJSON = withObject "Setup2SADevice" (pure . Setup2SADevice)
+
+
+instance ToJSON Setup2SADevice where
+  toJSON (Setup2SADevice o) = Object o
+
+
+-- | Extract a human-readable label from a 2SA setup device, falling back to @"(unknown)"@.
+setup2SADeviceLabel :: Setup2SADevice -> Text
+setup2SADeviceLabel (Setup2SADevice o) = fromMaybe "(unknown)" $ do
+  v <- lookup "phoneNumber" pairs <|> lookup "name" pairs
+  case v of
+    String t -> Just t
+    _ -> Nothing
+ where
+  pairs = KeyMap.toList o
+
+
+-- | Select a trusted phone from 'TrustData' for 2FA, prompting the user when multiple phones are available.  Returns 'Nothing' when the user opts for a trusted device instead.
+selectTwoFaPhone :: TrustData -> IO (Maybe TrustedPhone)
+selectTwoFaPhone td =
+  let phones = case tdList td of
+        TrustedPhoneNumbers ps -> toList ps
+        TrustedDevices _ -> []
+   in if tdNoTrustedDevices td
+        then pure (listToMaybe phones)
+        else pickPhoneOrDevice phones
+ where
+  pickPhoneOrDevice [] = pure Nothing
+  pickPhoneOrDevice phones = do
+    mapM_
+      (\(i, p) -> Text.putStrLn $ Text.pack (show (i :: Int)) <> ") " <> tpnNumberWithDialCode p)
+      (zip [1 ..] phones)
+    Text.putStrLn "Press Enter to use a trusted device, or select a phone number by its index to receive an SMS:"
+    response <- Text.getLine
+    if Text.null response
+      then pure Nothing
+      else case readMaybe (Text.unpack response) of
+        Just n | n >= (1 :: Int) && n <= length phones -> pure $ listToMaybe $ drop (n - 1) phones
+        _ -> pickPhoneOrDevice phones
+
+
+-- | Prompt the user to choose a trusted device to receive a legacy 2SA verification code.
+selectSetupDevice :: NonEmpty Setup2SADevice -> IO Setup2SADevice
+selectSetupDevice xs = do
+  Text.putStrLn "Please select a trusted device to receive a verification code"
+  mapM_ (\(i, d) -> Text.putStrLn $ Text.pack (show (i :: Int)) <> ") " <> setup2SADeviceLabel d) (zip [1 ..] (toList xs))
+  idx <- pleaseChooseN 1 (length xs)
+  pure (nthOf xs idx)
+
+
+simpleOptions :: Options
+simpleOptions = aesonPrefix camelCase
diff --git a/src/Network/HStratus/Http.hs b/src/Network/HStratus/Http.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Http.hs
@@ -0,0 +1,97 @@
+{- |
+Module      : Network.HStratus.Http
+Copyright   : (c) 2025 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+
+High-level HTTP client for the iCloud authentication API.
+
+= Typical usage
+
+Create an 'Api' handle with 'mkApi', choosing the 'Realm' that matches the
+user's region.  Then call 'login' to run the full sign-in flow: SRP credential
+exchange followed by any required two-factor ('completeTwoFactor') or two-step
+('complete2SA') challenge, then the account-login request.  On success 'login'
+returns 'Authenticated' carrying the refreshed 'Session' and 'AccountData'.
+
+= Injectable alternatives
+
+'login' resolves 2FA and 2SA challenges interactively using the prompts from
+"Network.HStratus.Trust".  Pass your own code-reader and device-selector to
+'loginWith' to bypass the interactive prompts — useful in tests or automation.
+
+If you already hold a 'Requires2FA' or 'Requires2SA' result from a prior call,
+resume the flow with 'completeTwoFactor' \/ 'completeTwoFactorWith' or
+'complete2SA' \/ 'complete2SAWith'.
+-}
+module Network.HStratus.Http
+  ( -- * API handle
+    mkApi
+  , mkApiWith
+
+    -- * Login
+  , login
+  , loginWith
+
+    -- * Fetching two-factor options
+  , fetchTrustData
+
+    -- * SMS phone code
+  , requestSmsCode
+  , verifySmsCode
+
+    -- * Completing two-factor challenges
+  , completeTwoFactor
+  , completeTwoFactorWith
+
+    -- * Completing two-step challenges
+  , complete2SA
+  , complete2SAWith
+
+    -- * Types
+  , Api
+  , AuthState (..)
+  , ApiLogger
+
+    -- * Authenticated HTTP
+  , rawRequest
+
+    -- * Logging
+  , withLogger
+  , fileLogger
+  , verboseLogger
+  , redactingLogger
+
+    -- * Errors
+  , AuthError (..)
+  , HStratusError
+  )
+where
+
+import Network.HStratus.Internal.Http.Api
+  ( Api
+  , ApiLogger
+  , fileLogger
+  , mkApi
+  , mkApiWith
+  , rawRequest
+  , redactingLogger
+  , verboseLogger
+  , withLogger
+  )
+import Network.HStratus.Internal.Http.Login
+  ( AuthState (..)
+  , complete2SA
+  , complete2SAWith
+  , completeTwoFactor
+  , completeTwoFactorWith
+  , login
+  , loginWith
+  )
+import Network.HStratus.Internal.Http.Signin
+  ( fetchTrustData
+  , requestSmsCode
+  , verifySmsCode
+  )
+import Network.HStratus.Internal.HttpErrors (AuthError (..), HStratusError)
+
diff --git a/src/Network/HStratus/Http/Common.hs b/src/Network/HStratus/Http/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Http/Common.hs
@@ -0,0 +1,34 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : Network.HStratus.Http.Common
+Copyright   : (c) 2025 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+
+Shared HTTP utilities for iCloud service clients.
+
+Re-exported from "Network.HStratus.Internal.Endpoints" for use by downstream
+libraries (@hstratus-drive@, @hstratus-notes@, etc.) that need to build
+authenticated service requests.
+-}
+module Network.HStratus.Http.Common
+  ( -- * Headers
+    icloudHome
+  , icloudBrowserHeaders
+  , withHeaders
+
+    -- * Request helpers
+  , stripTrailingSlash
+  , lookupWebservice
+  )
+where
+
+import Network.HStratus.Internal.Endpoints
+  ( icloudBrowserHeaders
+  , icloudHome
+  , lookupWebservice
+  , stripTrailingSlash
+  , withHeaders
+  )
+
diff --git a/src/Network/HStratus/Http/Endpoints.hs b/src/Network/HStratus/Http/Endpoints.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Http/Endpoints.hs
@@ -0,0 +1,42 @@
+{- |
+Module      : Network.HStratus.Http.Endpoints
+Copyright   : (c) 2025 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+
+Base URLs for the iCloud HTTP API, selected by region.
+
+iCloud operates two endpoint sets depending on the user's region:
+
+* 'Usual' — targets @icloud.com@; for users outside mainland China.
+* 'China' — targets @icloud.com.cn@; required for mainland China accounts.
+
+Use 'realmEndpoints' to obtain the 'Endpoints' for the appropriate 'Realm',
+then pass the result to 'Network.HStratus.Http.mkApi' or supply it directly to
+'Network.HStratus.Http.mkApiWith'.
+-}
+module Network.HStratus.Http.Endpoints
+  ( -- * Region selection
+
+    {- | The two regional iCloud endpoint sets.
+
+    * 'Usual' — @icloud.com@ family; for users outside mainland China.
+    * 'China' — @icloud.com.cn@ family; required for mainland China accounts.
+    -}
+    Realm (..)
+    -- | Return the 'Endpoints' for the given 'Realm'.
+  , realmEndpoints
+
+    -- * Endpoint bundle
+
+    {- | Base URLs and default request templates for the iCloud HTTP API.
+
+    Passed to 'Network.HStratus.Http.mkApi' or 'Network.HStratus.Http.mkApiWith'
+    to construct properly-targeted API calls.
+    -}
+  , Endpoints (..)
+  )
+where
+
+import Network.HStratus.Internal.Endpoints (Endpoints (..), Realm (..), realmEndpoints)
+
diff --git a/src/Network/HStratus/Internal/Http/Api.hs b/src/Network/HStratus/Internal/Http/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Internal/Http/Api.hs
@@ -0,0 +1,458 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_HADDOCK prune #-}
+
+module Network.HStratus.Internal.Http.Api
+  ( -- * API handle
+    ApiLogger (..)
+  , Api (..)
+  , mkApi
+  , mkApiWith
+  , withLogger
+  , fileLogger
+  , verboseLogger
+  , redactingLogger
+
+    -- * Authenticated HTTP
+  , rawRequest
+  , rawRequest'
+  , callApi
+  , asJson
+  , extractOr'
+  , showStatusOf
+
+    -- * Header helpers
+  , authHeaders
+  , requiredHeaders
+  , callRequiredHeaders
+
+    -- * Request builders
+  , maybeValue
+  , asObject
+  , mkJsonRequest
+  , withJsonRequestHeaders
+  , callHandlingResponse
+
+    -- * Constants
+  , hClientId
+
+    -- * Types
+  , AuthCode
+  )
+where
+
+import Control.Exception (throwIO)
+import Control.Monad (unless, when)
+import qualified Crypto.Hash.SHA256 as SHA256
+import Crypto.SRP
+  ( KnownAlgorithm (SHA256)
+  , PrimeGroup (G2048)
+  , digestSize
+  )
+import Data.Aeson
+  ( FromJSON (..)
+  , Key
+  , eitherDecode
+  , encode
+  )
+import Data.Aeson.KeyMap (fromList)
+import Data.Aeson.Types (Value (..))
+import Data.ByteString (ByteString, isPrefixOf)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as LBS
+import Data.CaseInsensitive (mk, original)
+import Data.Maybe (catMaybes)
+import qualified Data.Set as Set
+import Data.String.Conv (toS)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Time (getCurrentTime)
+import Network.HStratus.Http.Endpoints (Endpoints (..), Realm, realmEndpoints)
+import Network.HStratus.Internal.Endpoints
+  ( homeHeaders
+  , withAcceptJson
+  , withAppleOauthHeaders
+  , withBody
+  , withHeaders
+  , withWidgetKey
+  )
+import Network.HStratus.Internal.Http
+  ( hCounter
+  , hSessionId
+  , needsRetry
+  )
+import Network.HStratus.Internal.HttpErrors
+  ( ApiResponse
+  , AuthError (..)
+  , extractOr
+  )
+import Network.HStratus.Internal.PBKDF2 (FancyPseudoRandomF, wrapIO)
+import Network.HStratus.Internal.Session
+  ( SavedHeaders (..)
+  , cookiePath
+  , loadSavedHeaders
+  , updateSavedHeaders
+  , updateSessionSavedHeaders
+  )
+import Network.HStratus.Session (Session (..))
+import qualified Network.HStratus.Session as Session
+import Network.HTTP.Client
+  ( Manager
+  , Request (..)
+  , RequestBody (..)
+  , Response (..)
+  , httpLbs
+  )
+import Network.HTTP.Client.TLS (newTlsManager)
+import Network.HTTP.Types
+  ( HeaderName
+  , RequestHeaders
+  , Status (..)
+  , hAccept
+  , hContentType
+  )
+import System.Directory (doesFileExist)
+import System.IO (Handle, hPutStrLn)
+import System.Posix.Files (setFileMode)
+import Web.Cookie.Jar (usingCookiesFromFile)
+
+
+-- | A hook called after every HTTP response; receives the outgoing 'Request' and the 'Response'.
+newtype ApiLogger = ApiLogger (Request -> Response LBS.ByteString -> IO ())
+
+
+{- | Bundles the HTTP manager, session state, and endpoint configuration
+needed to call the iCloud API.  Created by 'mkApi' or 'mkApiWith'.
+-}
+data Api = Api
+  { apiManager :: !Manager
+  -- ^ shared TLS manager used for all requests
+  , apiSession :: !Session
+  -- ^ credentials and on-disk session paths
+  , apiEndpoints :: !Endpoints
+  -- ^ iCloud service URLs and the widget key
+  , apiHashAlgorithm :: !KnownAlgorithm
+  -- ^ hash algorithm used for SRP (always SHA-256 in practice)
+  , apiWrappedPseudoRF :: !FancyPseudoRandomF
+  -- ^ PBKDF2 pseudo-random function, pre-wrapped with the hash algorithm
+  , apiGroup :: !PrimeGroup
+  -- ^ SRP prime group (always G2048 in practice)
+  , apiLogger :: !(Maybe ApiLogger)
+  -- ^ optional logger invoked after every HTTP response
+  }
+
+
+{- | Create an 'Api' using the endpoint set for the given 'Realm'.
+
+Loads credentials and session state from disk via 'Network.HStratus.Session.loadSession'
+and creates a new TLS manager.  Use 'mkApiWith' to supply a pre-built manager
+and endpoint set — for example in tests.
+-}
+mkApi :: Realm -> IO Api
+mkApi realm = do
+  let apiHashAlgorithm = SHA256
+      apiGroup = G2048
+      apiEndpoints = realmEndpoints realm
+      apiLogger = Nothing
+  apiManager <- newTlsManager
+  apiSession <- Session.loadSession
+  apiWrappedPseudoRF <- wrapIO SHA256.hmac $ digestSize apiHashAlgorithm
+  pure
+    Api
+      { apiGroup
+      , apiEndpoints
+      , apiManager
+      , apiHashAlgorithm
+      , apiSession
+      , apiWrappedPseudoRF
+      , apiLogger
+      }
+
+
+-- | Create an 'Api' from a pre-built 'Session', 'Endpoints', and 'Manager'. Use this when you need to supply your own HTTP manager or a custom endpoint set — for example in tests.
+mkApiWith :: Session -> Endpoints -> Manager -> IO Api
+mkApiWith apiSession apiEndpoints apiManager = do
+  let apiHashAlgorithm = SHA256
+      apiGroup = G2048
+      apiLogger = Nothing
+  apiWrappedPseudoRF <- wrapIO SHA256.hmac $ digestSize apiHashAlgorithm
+  pure
+    Api
+      { apiGroup
+      , apiEndpoints
+      , apiManager
+      , apiHashAlgorithm
+      , apiSession
+      , apiWrappedPseudoRF
+      , apiLogger
+      }
+
+
+-- | Attach a logger to an 'Api'; it is called after every HTTP response.
+withLogger :: ApiLogger -> Api -> Api
+withLogger logger api = api{apiLogger = Just logger}
+
+
+{- | Build an 'ApiLogger' that appends one entry per response to a 'Handle'.
+
+Each entry contains:
+
+* a summary line: @TIMESTAMP METHOD URL STATUS@
+* one response header per line: @Name: value@
+* the raw response body
+* a @---@ separator
+
+__Security warning:__ all request and response headers are written verbatim,
+including @Set-Cookie@, @X-Apple-Session-Token@, @X-Apple-TwoSV-Trust-Token@,
+and @scnt@. Log files produced by this logger may contain live session tokens.
+Use 'redactingLogger' when the log destination is not fully trusted.
+
+Not safe for concurrent use from multiple threads against the same handle.
+-}
+fileLogger :: Handle -> ApiLogger
+fileLogger h = ApiLogger $ \req resp -> do
+  now <- getCurrentTime
+  let scheme = if secure req then "https" else "http" :: String
+      uri = scheme <> "://" <> toS (host req) <> toS (path req)
+      status = statusCode (responseStatus resp)
+      summary = show now <> " " <> toS (method req) <> " " <> uri <> " " <> show status
+      fmtHdr (name, val) = toS (original name) <> ": " <> toS val
+  hPutStrLn h summary
+  mapM_ (hPutStrLn h . fmtHdr) (requestHeaders req)
+  hPutStrLn h ""
+  mapM_ (hPutStrLn h . fmtHdr) (responseHeaders resp)
+  hPutStrLn h ""
+  LBS.hPutStr h (responseBody resp)
+  hPutStrLn h "\n---"
+
+
+{- | Like 'fileLogger' but also logs the query string in the URL and the
+request body when present.
+
+__Security warning:__ carries the same token-exposure risk as 'fileLogger' and
+additionally logs request bodies, which may contain passwords or SRP parameters.
+Use 'redactingLogger' when the log destination is not fully trusted.
+-}
+verboseLogger :: Handle -> ApiLogger
+verboseLogger h = ApiLogger $ \req resp -> do
+  now <- getCurrentTime
+  let scheme = if secure req then "https" else "http" :: String
+      qs = if BS.null (queryString req) then "" else "?" <> toS (queryString req)
+      uri = scheme <> "://" <> toS (host req) <> toS (path req) <> qs
+      status = statusCode (responseStatus resp)
+      summary = show now <> " " <> toS (method req) <> " " <> uri <> " " <> show status
+      fmtHdr (name, val) = toS (original name) <> ": " <> toS val
+  hPutStrLn h summary
+  mapM_ (hPutStrLn h . fmtHdr) (requestHeaders req)
+  logReqBody (requestBody req)
+  hPutStrLn h ""
+  mapM_ (hPutStrLn h . fmtHdr) (responseHeaders resp)
+  hPutStrLn h ""
+  LBS.hPutStr h (responseBody resp)
+  hPutStrLn h "\n---"
+ where
+  logReqBody (RequestBodyLBS lbs)
+    | not (LBS.null lbs) = hPutStrLn h "" >> LBS.hPutStr h lbs
+  logReqBody (RequestBodyBS bs)
+    | not (BS.null bs) = hPutStrLn h "" >> LBS.hPutStr h (LBS.fromStrict bs)
+  logReqBody _ = pure ()
+
+
+{- | Like 'fileLogger' but replaces the values of sensitive headers with
+@\<redacted\>@ before writing.
+
+The following headers are redacted in both request and response:
+@Set-Cookie@, @Cookie@, @X-Apple-Session-Token@, @X-Apple-TwoSV-Trust-Token@,
+@scnt@, @Authorization@.
+
+Safe to write to shared or untrusted log destinations.
+-}
+redactingLogger :: Handle -> ApiLogger
+redactingLogger h = ApiLogger $ \req resp -> do
+  now <- getCurrentTime
+  let scheme = if secure req then "https" else "http" :: String
+      uri = scheme <> "://" <> toS (host req) <> toS (path req)
+      status = statusCode (responseStatus resp)
+      summary = show now <> " " <> toS (method req) <> " " <> uri <> " " <> show status
+      fmtHdr (name, val) = toS (original name) <> ": " <> toS val
+  hPutStrLn h summary
+  mapM_ (hPutStrLn h . fmtHdr . redactHeader) (requestHeaders req)
+  hPutStrLn h ""
+  mapM_ (hPutStrLn h . fmtHdr . redactHeader) (responseHeaders resp)
+  hPutStrLn h ""
+  LBS.hPutStr h (responseBody resp)
+  hPutStrLn h "\n---"
+
+
+sensitiveHeaderNames :: Set.Set HeaderName
+sensitiveHeaderNames =
+  Set.fromList
+    [ mk "Set-Cookie"
+    , mk "Cookie"
+    , mk "X-Apple-Session-Token"
+    , mk "X-Apple-TwoSV-Trust-Token"
+    , mk "scnt"
+    , mk "Authorization"
+    ]
+
+
+redactHeader :: (HeaderName, BS.ByteString) -> (HeaderName, BS.ByteString)
+redactHeader (name, val)
+  | name `Set.member` sensitiveHeaderNames = (name, "<redacted>")
+  | otherwise = (name, val)
+
+
+-- | Make a session request and obtain the raw byte results
+rawRequest :: Api -> Request -> IO (Response LBS.ByteString)
+rawRequest = rawRequest' True
+
+
+-- | Make a session request and obtain the raw byte results
+rawRequest' :: Bool -> Api -> Request -> IO (Response LBS.ByteString)
+rawRequest' mayRetry api req = do
+  let Api{apiManager = mgr, apiSession = s, apiLogger = mbLogger} = api
+      jarPath = cookiePath (sessionTopDir s) (sessionCreds s)
+  resp <- usingCookiesFromFile jarPath req $ flip httpLbs mgr
+  jarExists <- doesFileExist jarPath
+  when jarExists $ setFileMode jarPath 0o600
+  updateSessionSavedHeaders s $ updateSavedHeaders $ responseHeaders resp
+  mapM_ (\(ApiLogger logFn) -> logFn req resp) mbLogger
+  if mayRetry && needsRetry (statusCode (responseStatus resp))
+    then rawRequest' False api req
+    else pure resp
+
+
+{- | Make a session request to obtain a JSON payload
+
+call api with request, obtain response
+save the sessionData from the response headers
+save any cookies from the response headers
+if the response is JSON, parse it, and see if it parses as an ApiError
+if JSON parsing fails, log to stderr
+if it parses as an ApiError, indicate that
+
+if the response is not JSON, use 'rawRequest' instead
+-}
+callApi
+  :: (FromJSON a) => Api -> Request -> IO (Response (ApiResponse a))
+callApi api req = do
+  let isJsonType ct = "application/json" `isPrefixOf` ct || "text/json" `isPrefixOf` ct
+  raw <- rawRequest api req
+  let code = statusCode (responseStatus raw)
+      theType = lookup hContentType $ responseHeaders raw
+      isJson = maybe False isJsonType theType
+  when (code >= 400 && LBS.null (responseBody raw)) $
+    throwIO $
+      UnexpectedResponse $
+        showStatusOf raw
+  unless (code >= 400 || isJson) $
+    throwIO $
+      UnexpectedResponse $
+        "response was not JSON: " <> toS (show theType)
+  mapM asJson raw
+
+
+-- confirm the content-type of the response before attempting to parse
+-- if it's wrong, throw  InvalidContentType
+-- try to parse, if that fails, throw WrongDataType
+asJson :: (FromJSON a) => LBS.ByteString -> IO a
+asJson resp = case eitherDecode resp of
+  Left _err -> throwIO $ UnexpectedResponse "did not decode JSON response correctly"
+  Right x -> pure x
+
+
+-- | Extract the successful value from a response, throwing 'UnexpectedResponse' for 4xx\/5xx status codes.
+extractOr' :: Response (ApiResponse a) -> IO a
+extractOr' r | statusCode (responseStatus r) >= 400 = throwIO $ UnexpectedResponse $ showStatusOf r
+extractOr' r = extractOr $ responseBody r
+
+
+-- | Format the HTTP status of a response as a human-readable 'Text' string.
+showStatusOf :: Response a -> Text
+showStatusOf resp =
+  let showResponse' x s | x >= 500 = "server error:" <> Text.pack (show s)
+      showResponse' x s | x >= 400 = "bad request:" <> Text.pack (show s)
+      showResponse' _x s = "ok:" <> Text.pack (show s)
+      theStatus = responseStatus resp
+      theCode = statusCode theStatus
+   in showResponse' theCode theStatus
+
+
+-- | Build the full set of Apple OAuth request headers for an authenticated call.
+authHeaders :: Api -> SavedHeaders -> RequestHeaders
+authHeaders api savedHdrs =
+  let Api{apiSession = session, apiEndpoints = ep} = api
+      Session{sessionClientId = cid} = session
+      headerOf name x = (name, toS x)
+      maybeHeaderOf name = fmap (headerOf name)
+      cidHeader = [(hClientId, toS cid)]
+      sdHeaders =
+        catMaybes
+          [ maybeHeaderOf hCounter $ shCounter savedHdrs
+          , maybeHeaderOf hSessionId $ shSessionId savedHdrs
+          ]
+   in withAppleOauthHeaders (epWidgetKey ep) $ homeHeaders ep <> sdHeaders <> cidHeader
+
+
+-- | Build the minimal request headers required by the iCloud API: the widget key, session counter, and session ID.
+requiredHeaders :: ByteString -> SavedHeaders -> RequestHeaders
+requiredHeaders key savedHdrs =
+  let headerOf name x = (name, toS x)
+      maybeHeaderOf name = fmap (headerOf name)
+      sdHeaders =
+        catMaybes
+          [ maybeHeaderOf hCounter $ shCounter savedHdrs
+          , maybeHeaderOf hSessionId $ shSessionId savedHdrs
+          ]
+   in withAcceptJson . withWidgetKey key $ sdHeaders
+
+
+-- | Make an authenticated API call with the required session headers, returning the decoded response body or throwing on error.
+callRequiredHeaders :: (FromJSON a) => Api -> Request -> IO a
+callRequiredHeaders api@Api{apiSession = s, apiEndpoints = ep} req = do
+  savedHdrs <- loadSavedHeaders s
+  callApi api (withHeaders (requiredHeaders (epWidgetKey ep) savedHdrs) req) >>= extractOr'
+
+
+-- | Convert a @Maybe@ to a JSON 'Value', using 'Null' for 'Nothing'.
+maybeValue :: (a -> Value) -> Maybe a -> Value
+maybeValue = maybe Null
+
+
+-- | Build a JSON 'Object' 'Value' from a list of key-value pairs.
+asObject :: [(Key, Value)] -> Value
+asObject = Object . fromList
+
+
+-- | Build a JSON request by combining a base-request builder, a body encoder, and their respective inputs.
+mkJsonRequest :: (a -> Request) -> (b -> Value) -> a -> b -> Request
+mkJsonRequest mkBase mkBody baseSrc bodySrc =
+  withJsonRequestHeaders . withBody (encode $ mkBody bodySrc) $ mkBase baseSrc
+
+
+-- | Set @Accept: application/json@ and @Content-Type: application/json@ on a request.
+withJsonRequestHeaders :: Request -> Request
+withJsonRequestHeaders = withHeaders [(hAccept, "application/json"), (hContentType, "application/json")]
+
+
+-- | Build and execute an authenticated API call, applying an optional request modifier, and decode the response.
+callHandlingResponse
+  :: (FromJSON a)
+  => (Endpoints -> b -> Request)
+  -> (Request -> Request)
+  -> Api
+  -> b
+  -> IO a
+callHandlingResponse mkReq modReq api@Api{apiEndpoints} x =
+  callApi api (modReq $ mkReq apiEndpoints x) >>= extractOr'
+
+
+-- | @HeaderName@ used to represent API session data
+hClientId :: HeaderName
+hClientId = mk "X-Apple-OAuth-State"
+
+
+{- | The code sent to a user device that the user must enter to confirm
+authenticity
+-}
+type AuthCode = Text
diff --git a/src/Network/HStratus/Internal/Http/Login.hs b/src/Network/HStratus/Internal/Http/Login.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Internal/Http/Login.hs
@@ -0,0 +1,336 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# OPTIONS_HADDOCK prune #-}
+
+module Network.HStratus.Internal.Http.Login
+  ( -- * Login state
+    AuthState (..)
+
+    -- * Login
+  , login
+  , loginWith
+
+    -- * Completing two-factor challenges
+  , completeTwoFactor
+  , completeTwoFactorWith
+
+    -- * Completing two-step challenges
+  , complete2SA
+  , complete2SAWith
+  )
+where
+
+import Control.Exception (IOException, catch, throwIO)
+import Control.Monad.IO.Class (MonadIO, liftIO)
+import Control.Monad.Trans.Reader (ReaderT, runReaderT)
+import qualified Control.Monad.Trans.Reader as Reader
+import Crypto.SRP (calcResults, mkFromClient)
+import Data.Aeson (FromJSON (..))
+import Data.Aeson.Types (Value (..), parseEither)
+import Data.List.NonEmpty (NonEmpty)
+import qualified Data.Text as Text
+import Data.Word (Word8)
+import Network.HStratus.Internal.Http (SrpContext (..))
+import Network.HStratus.Internal.Http.Api
+  ( Api (..)
+  , AuthCode
+  )
+import Network.HStratus.Internal.Http.Signin
+  ( accountLogin
+  , doTrustStep
+  , fetchTrustData
+  , listSetupDevices
+  , requestSmsCode
+  , runSigninComplete
+  , runSigninInit
+  , sendSetupVerification
+  , triggerTwoFaPush
+  , validate
+  , validateSetupVerification
+  , verifySmsCode
+  , verifyTwoFaCode
+  )
+import Network.HStratus.Internal.HttpErrors (AuthError (..))
+import Network.HStratus.Internal.LoginFSM
+  ( AfterAcctLogin (..)
+  , AfterArtifactDir (..)
+  , AfterCredentials (..)
+  , AfterLoadLastSession (..)
+  , AfterMkArtifactDir (..)
+  , AfterSrpComplete (..)
+  , AfterTwoFaVerify (..)
+  , AfterTwoSaVerify (..)
+  , AfterValidateSession (..)
+  , CompletionOutcome (..)
+  , LoginEvent (..)
+  , LoginFSM (..)
+  , LoginOutcome (..)
+  , TwoFaConfig (..)
+  , TwoSaConfig (..)
+  , loginProcess
+  , twoFaProcess
+  , twoSaProcess
+  )
+import Network.HStratus.Internal.Session
+  ( accountDataRequires2FA
+  , accountDataRequires2SA
+  , loadAccountData
+  , loadSavedHeaders
+  , pristine
+  , saveAccountData
+  , saveLoginMsg
+  , unknownAccountData
+  )
+import Network.HStratus.Internal.Trust
+  ( CodeStatus (..)
+  , TrustData (..)
+  , TrustedPhone
+  , pleaseReadCode
+  , selectSetupDevice
+  , selectTwoFaPhone
+  )
+import Network.HStratus.Session (AccountData (..), Credentials (..), Session (..))
+import Network.HStratus.Trust (Setup2SADevice (..))
+import System.Directory (createDirectoryIfMissing, doesDirectoryExist)
+
+
+newtype LoginM a = LoginM {runLoginM :: ReaderT Api IO a}
+  deriving (Functor, Applicative, Monad, MonadIO)
+
+
+ask :: LoginM Api
+ask = LoginM Reader.ask
+
+
+asks :: (Api -> b) -> LoginM b
+asks = LoginM . Reader.asks
+
+
+{- | The result of a login attempt.
+
+'login' and 'loginWith' return only 'Authenticated'; 2FA and 2SA challenges
+are resolved internally.  'Requires2FA' and 'Requires2SA' are only produced by
+'completeTwoFactor', 'completeTwoFactorWith', 'complete2SA', and 'complete2SAWith'.
+-}
+data AuthState
+  = -- | Sign-in succeeded; the 'Session' is refreshed and 'AccountData' is available.
+    Authenticated Session AccountData
+  | -- | Sign-in requires a two-factor code; use 'completeTwoFactor' or 'completeTwoFactorWith' to proceed.
+    Requires2FA Session
+  | -- | Sign-in requires a legacy two-step code; use 'complete2SA' or 'complete2SAWith' to proceed.
+    Requires2SA Session (NonEmpty Setup2SADevice)
+
+
+instance Show AuthState where
+  show (Authenticated _ ad) = "Authenticated <session> " ++ show ad
+  show (Requires2FA _) = "Requires2FA <session>"
+  show (Requires2SA _ ds) = "Requires2SA <session> " ++ show ds
+
+
+-- | Logs into ICloud, completing any 2FA or 2SA challenge automatically
+login :: Api -> IO AuthState
+login = loginWith pleaseReadCode selectTwoFaPhone selectSetupDevice
+
+
+-- | Like 'login' with injectable code prompt, phone selector, and device selector, for testing
+loginWith
+  :: (Word8 -> IO AuthCode)
+  -> (TrustData -> IO (Maybe TrustedPhone))
+  -> (NonEmpty Setup2SADevice -> IO Setup2SADevice)
+  -> Api
+  -> IO AuthState
+loginWith readCode pickPhone pickDevice api =
+  runReaderT (runLoginM loginProcess) api >>= \case
+    LoginAuthenticated (AuthComplete _ ad) -> pure $ Authenticated (apiSession api) ad
+    LoginNeedsTwoFa (NeedsTwoFa _) -> completeTwoFactorWith readCode pickPhone api
+    LoginNeedsTwoSa (TwoSaReady _ ds) -> complete2SAWith pickDevice (readCode 6) api ds
+    LoginHaltCreds _ -> throwIO CredentialsMissing
+    LoginHaltDir _ -> throwIO $ ArtifactDirCreationFailed (sessionTopDir (apiSession api))
+    LoginHaltSrp _ -> throwIO SrpProtocolError
+    LoginHaltTwoFaLocked _ -> throwIO TwoFactorLocked
+
+
+instance LoginEvent LoginM where
+  type State LoginM = LoginFSM
+
+
+  initial = pure RatifyCredentials
+
+
+  ratifyCreds RatifyCredentials =
+    asks (GotCreds . RatifyArtifactDir . sessionCreds . apiSession)
+
+
+  ratifyArtifactDir (RatifyArtifactDir creds) = do
+    dir <- sessionTopDir . apiSession <$> ask
+    exists <- liftIO $ doesDirectoryExist dir
+    pure $
+      if exists
+        then DirPresent $ LoadLastSession creds
+        else DirAbsent $ MkArtifactDir creds
+
+
+  mkArtifactDir (MkArtifactDir creds) = do
+    dir <- sessionTopDir . apiSession <$> ask
+    ok <- liftIO $ (createDirectoryIfMissing True dir >> pure True) `catch` (\(_ :: IOException) -> pure False)
+    pure $
+      if ok
+        then DirMade $ LoadLastSession creds
+        else NotMade $ HaltCannotMkArtifactDir creds
+
+
+  loadSession (LoadLastSession creds) = do
+    savedHdrs <- ask >>= liftIO . loadSavedHeaders . apiSession
+    pure $
+      if savedHdrs == pristine
+        then HasClientId $ ReadyToAuth creds savedHdrs
+        else HasPriorSession $ HasSavedSession creds savedHdrs
+
+
+  validateSession (HasSavedSession creds savedHdrs) = do
+    valid <- ask >>= liftIO . validate
+    if not valid
+      then pure $ SessionStale $ ReadyToAuth creds savedHdrs
+      else do
+        mbAd <- ask >>= liftIO . loadAccountData . apiSession
+        pure $ case mbAd of
+          Just ad | accountDataRequires2FA ad -> SessionStale $ ReadyToAuth creds savedHdrs
+          Just ad | accountDataRequires2SA ad -> SessionStale $ ReadyToAuth creds savedHdrs
+          Just ad -> SessionStillValid $ AuthComplete creds ad
+          Nothing -> SessionStillValid $ AuthComplete creds unknownAccountData
+
+
+  srpInit (ReadyToAuth creds _) = do
+    api <- ask
+    let user = credAccountName creds
+        pass = credPassword creds
+    fc <- liftIO $ mkFromClient user pass (apiGroup api)
+    (fs, kd) <- liftIO $ runSigninInit api fc
+    pure $ SrpInitDone creds (SrpContext fc fs kd)
+
+
+  srpComplete (SrpInitDone creds ctx) = do
+    api <- ask
+    let SrpContext{srpFromClient = fc, srpFromServer = fs, srpKeyDeriver = kd} = ctx
+    case calcResults kd fc fs of
+      Nothing -> pure $ SrpCompleteInvalidKey $ HaltInvalidSrp creds
+      Just results -> do
+        liftIO $ runSigninComplete api kd results
+        pure $ SrpCompleteOk $ DoAccountLogin creds
+
+
+  acctLogin (DoAccountLogin creds) = do
+    api <- ask
+    loginReply <- liftIO $ accountLogin api
+    ad <- liftIO $ parseAccountData loginReply
+    liftIO $ saveLoginMsg (apiSession api) loginReply
+    liftIO $ saveAccountData (apiSession api) ad
+    pure $
+      if
+        | accountDataRequires2SA ad -> AcctLogin2SA $ NeedsTwoSa creds
+        | accountDataRequires2FA ad -> AcctLogin2FA $ NeedsTwoFa creds
+        | otherwise -> AcctLoginOk $ AuthComplete creds ad
+
+
+  listTwoSaDevices (NeedsTwoSa creds) = do
+    ask >>= fmap (TwoSaReady creds) . liftIO . listSetupDevices
+
+
+  beginTwoFa (ReadyForTwoFa creds td) TwoFaConfig{tfcPickPhone} = do
+    mbPhone <- liftIO $ tfcPickPhone td
+    case mbPhone of
+      Nothing -> ask >>= liftIO . triggerTwoFaPush
+      Just phone -> ask >>= liftIO . flip requestSmsCode phone
+    pure $ TwoFaVerifying creds td mbPhone
+
+
+  verifyTwoFa (TwoFaVerifying creds td mbPhone) TwoFaConfig{tfcReadCode} = do
+    api <- ask
+    code <- liftIO $ tfcReadCode (scLength (tdSecurityCode td))
+    ok <- liftIO $ case mbPhone of
+      Nothing -> verifyTwoFaCode api code
+      Just phone -> verifySmsCode api phone code
+    if ok
+      then pure $ TwoFaOk $ DoTrust creds
+      else do
+        freshTd <- liftIO $ fetchTrustData api
+        let cs = tdSecurityCode freshTd
+        pure $
+          if scTooManyCodesValidated cs || scSecurityCodeLocked cs || scSecurityCodeCooldown cs
+            then TwoFaLocked $ HaltTwoFaLocked creds
+            else TwoFaRetry $ ReadyForTwoFa creds freshTd
+
+
+  doTrust (DoTrust creds) = do
+    ask >>= liftIO . doTrustStep
+    pure $ DoAccountLogin creds
+
+
+  beginTwoSa (ReadyForTwoSa creds devices) TwoSaConfig{tscPickDevice} = do
+    api <- ask
+    device <- liftIO $ tscPickDevice devices
+    liftIO $ sendSetupVerification api device
+    pure $ TwoSaVerifying creds device devices
+
+
+  verifyTwoSa (TwoSaVerifying creds device devices) TwoSaConfig{tscReadCode} = do
+    api <- ask
+    code <- liftIO tscReadCode
+    ok <- liftIO $ validateSetupVerification api device code
+    pure $
+      if ok
+        then TwoSaOk $ DoAccountLogin creds
+        else TwoSaRetry $ ReadyForTwoSa creds devices
+
+
+parseAccountData :: Value -> IO AccountData
+parseAccountData v =
+  either (throwIO . UnexpectedResponse . Text.pack) pure $
+    parseEither parseJSON v
+
+
+-- | Complete a pending 2FA (auth-endpoint) challenge
+completeTwoFactor :: Api -> IO AuthState
+completeTwoFactor = completeTwoFactorWith pleaseReadCode (\_ -> pure Nothing)
+
+
+-- | Like 'completeTwoFactor' with an injectable code prompt and phone selector, for testing
+completeTwoFactorWith :: (Word8 -> IO AuthCode) -> (TrustData -> IO (Maybe TrustedPhone)) -> Api -> IO AuthState
+completeTwoFactorWith readCode pickPhone api = do
+  td <- fetchTrustData api
+  let start = ReadyForTwoFa (sessionCreds (apiSession api)) td
+      cfg = TwoFaConfig{tfcPickPhone = pickPhone, tfcReadCode = readCode}
+  runReaderT (runLoginM (twoFaProcess start cfg)) api >>= \case
+    CompletionAuthenticated (AuthComplete _ ad) -> pure $ Authenticated (apiSession api) ad
+    CompletionNeedsTwoFa _ -> throwIO TwoFactorStillRequired
+    CompletionNeedsTwoSa (TwoSaReady _ ds) -> pure $ Requires2SA (apiSession api) ds
+    CompletionTwoFaLocked _ -> throwIO TwoFactorLocked
+
+
+-- | Used when already holding a 'Requires2SA' result from 'completeTwoFactor' or 'completeTwoFactorWith'
+complete2SA :: Api -> NonEmpty Setup2SADevice -> IO AuthState
+complete2SA = complete2SAWith selectSetupDevice (pleaseReadCode 6)
+
+
+-- | Like 'complete2SA' with injectable device selector and code prompt, for testing
+complete2SAWith
+  :: (NonEmpty Setup2SADevice -> IO Setup2SADevice)
+  -> IO AuthCode
+  -> Api
+  -> NonEmpty Setup2SADevice
+  -> IO AuthState
+complete2SAWith pickDevice readCode api devices = do
+  let start = ReadyForTwoSa (sessionCreds (apiSession api)) devices
+      cfg = TwoSaConfig{tscPickDevice = pickDevice, tscReadCode = readCode}
+  runReaderT (runLoginM (twoSaProcess start cfg)) api >>= \case
+    CompletionAuthenticated (AuthComplete _ ad) -> pure $ Authenticated (apiSession api) ad
+    CompletionNeedsTwoFa _ -> throwIO TwoFactorStillRequired
+    CompletionNeedsTwoSa (TwoSaReady _ ds) -> pure $ Requires2SA (apiSession api) ds
+    CompletionTwoFaLocked _ -> throwIO TwoFactorLocked
diff --git a/src/Network/HStratus/Internal/Http/Signin.hs b/src/Network/HStratus/Internal/Http/Signin.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Internal/Http/Signin.hs
@@ -0,0 +1,420 @@
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedLists #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# OPTIONS_HADDOCK prune #-}
+
+{- |
+Module      : Network.HStratus.Internal.Http.Signin
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Implements the iCloud two-factor sign-in flow: fetching trust data, requesting and verifying SMS codes, and completing sign-in.
+-}
+module Network.HStratus.Internal.Http.Signin
+  ( -- * Trust data
+    fetchTrustData
+
+    -- * SMS code
+  , requestSmsCode
+  , verifySmsCode
+
+    -- * Session validation
+  , validate
+
+    -- * SRP sign-in
+  , runSigninInit
+  , runSigninComplete
+
+    -- * Account login
+  , accountLogin
+
+    -- * 2FA helpers
+  , triggerTwoFaPush
+  , doTrustStep
+  , verifyTwoFaCode
+
+    -- * 2SA helpers
+  , listSetupDevices
+  , sendSetupVerification
+  , validateSetupVerification
+  )
+where
+
+import Control.Exception (IOException, catch, throwIO)
+import Control.Monad (unless, void)
+import Crypto.SRP
+  ( FromClient (..)
+  , FromServer (..)
+  , Results (..)
+  )
+import Data.Aeson
+  ( FromJSON (..)
+  , Object
+  , encode
+  , withObject
+  , (.:)
+  )
+import Data.Aeson.Types (Parser, Value (..))
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Base64 as B64
+import Data.List.NonEmpty (NonEmpty)
+import qualified Data.List.NonEmpty as NE
+import Data.Maybe (fromMaybe)
+import Data.String.Conv (toS)
+import Data.Text (Text)
+import Data.Text.Encoding (decodeUtf8, encodeUtf8)
+import Data.Word (Word64)
+import Network.HStratus.Http.Endpoints (Endpoints (..))
+import Network.HStratus.Internal.Endpoints
+  ( accountLoginBase
+  , extendPath
+  , homeHeaders
+  , listDevices
+  , sendVerification
+  , signinCompleteBase
+  , signinInitBase
+  , toPut
+  , twoFaOptionsBase
+  , twoSvTrust
+  , validateBase
+  , validateVerification
+  , verifySecurityCodeReq
+  , withAcceptJson
+  , withBody
+  , withHeaders
+  )
+import Network.HStratus.Internal.Http
+  ( KeyDeriver (..)
+  , PasswordProtocol (..)
+  , phoneCodeBody
+  , phoneTriggerBody
+  , validateSetupBody
+  )
+import Network.HStratus.Internal.Http.Api
+  ( Api (..)
+  , AuthCode
+  , asObject
+  , authHeaders
+  , callApi
+  , callHandlingResponse
+  , callRequiredHeaders
+  , extractOr'
+  , maybeValue
+  , mkJsonRequest
+  , rawRequest
+  , requiredHeaders
+  , showStatusOf
+  , withJsonRequestHeaders
+  )
+import Network.HStratus.Internal.HttpErrors
+  ( ApiResponse
+  , AuthError (..)
+  )
+import Network.HStratus.Internal.Session
+  ( SavedHeaders (..)
+  , loadSavedHeaders
+  )
+import Network.HStratus.Internal.Trust (TrustData (..), TrustedPhone)
+import Network.HStratus.Session (Credentials (..), Session (..))
+import Network.HStratus.Trust (Setup2SADevice (..))
+import Network.HTTP.Client (Request (..), Response (..))
+import Network.HTTP.Types (Status (..))
+
+
+-- | Fetch the 2FA options immediately after the 409 from signin/complete
+fetchTrustData :: Api -> IO TrustData
+fetchTrustData api = do
+  savedHdrs <- loadSavedHeaders (apiSession api)
+  let req = withHeaders (withAcceptJson $ authHeaders api savedHdrs) (twoFaOptionsBase (apiEndpoints api))
+  callApi api req >>= extractOr'
+
+
+-- | POST to phone/securitycode to request an SMS code to the given phone
+requestSmsCode :: Api -> TrustedPhone -> IO ()
+requestSmsCode api@Api{apiEndpoints = ep} tp = do
+  savedHdrs <- loadSavedHeaders (apiSession api)
+  let req =
+        withHeaders (authHeaders api savedHdrs) $
+          withJsonRequestHeaders $
+            withBody (encode $ phoneTriggerBody tp) $
+              toPut (extendPath (epAuth ep) "/verify/phone")
+  resp <- rawRequest api req
+  unless (statusCode (responseStatus resp) < 400) $
+    throwIO $
+      UnexpectedResponse $
+        showStatusOf resp
+
+
+-- | POST to phone/securitycode to verify an SMS code; returns True when accepted
+verifySmsCode :: Api -> TrustedPhone -> AuthCode -> IO Bool
+verifySmsCode api@Api{apiEndpoints = ep} tp code = do
+  savedHdrs <- loadSavedHeaders (apiSession api)
+  let req =
+        withHeaders (authHeaders api savedHdrs) $
+          withJsonRequestHeaders $
+            withBody (encode $ phoneCodeBody tp code) $
+              verifySecurityCodeReq "phone" ep
+  resp <- rawRequest api req
+  let c = statusCode (responseStatus resp)
+  if
+    | c < 400 -> pure True
+    | c == 400 -> pure False
+    | otherwise -> throwIO $ UnexpectedResponse $ showStatusOf resp
+
+
+data SigninInitReply = SigninInitReply
+  { sirTag :: !Text
+  , sirProtocol :: !PasswordProtocol
+  , sirPublicBytes :: !ByteString
+  , sirIterations :: !Word64
+  , sirSalt :: !ByteString
+  }
+  deriving (Eq, Show)
+
+
+instance FromJSON SigninInitReply where
+  parseJSON = withObject "SigninInitReply" parseSigninInitReply
+
+
+parseSigninInitReply :: Object -> Parser SigninInitReply
+parseSigninInitReply o =
+  let tag = o .: "c"
+      iterations = o .: "iteration"
+      protocol = o .: "protocol"
+      publicBytes = o .: "b" >>= parseBase64Bytes
+      salt = o .: "salt" >>= parseBase64Bytes
+      parseBase64Bytes s = case B64.decode (encodeUtf8 s) of
+        Left err -> fail err
+        Right b -> pure b
+   in SigninInitReply
+        <$> tag
+        <*> protocol
+        <*> publicBytes
+        <*> iterations
+        <*> salt
+
+
+signinInit :: Api -> FromClient -> IO SigninInitReply
+signinInit api other = do
+  savedHdrs <- loadSavedHeaders (apiSession api)
+  callHandlingResponse signinInitReq (withHeaders (authHeaders api savedHdrs)) api other
+
+
+-- | Execute the SRP init step, returning the server's public values and a 'KeyDeriver' for the completion step.
+runSigninInit :: Api -> FromClient -> IO (FromServer, KeyDeriver)
+runSigninInit api fc = do
+  r <- signinInit api fc
+  let fromServer =
+        FromServer
+          { fsPublicBytes = sirPublicBytes r
+          , fsSalt = sirSalt r
+          , fsPrimeGroup = apiGroup api
+          , fsKnownAlgorithm = apiHashAlgorithm api
+          }
+      keyDeriver =
+        KeyDeriver
+          { kdTag = sirTag r
+          , kdIterations = sirIterations r
+          , kdWrappedF = apiWrappedPseudoRF api
+          , kdProtocol = sirProtocol r
+          }
+  pure (fromServer, keyDeriver)
+
+
+signinInitReq :: Endpoints -> FromClient -> Request
+signinInitReq = mkJsonRequest signinInitBase signinInitValue
+
+
+signinInitValue :: FromClient -> Value
+signinInitValue fc =
+  let a = decodeUtf8 $ B64.encode $ fcPublicBytes fc
+   in asObject
+        [ ("a", String a)
+        , ("accountName", String (fcUser fc))
+        , ("protocols", Array ["s2k", "s2k_fo"])
+        ]
+
+
+data SigninCompletion = SigninCompletion
+  { siTag :: !Text
+  , siAccountName :: !Text
+  , siSavedHeaders :: !SavedHeaders
+  , siResults :: !Results
+  }
+
+
+-- | Execute the SRP completion step, sending the client and server proofs to Apple.
+runSigninComplete :: Api -> KeyDeriver -> Results -> IO ()
+runSigninComplete api@Api{apiSession = session} kd results = do
+  siSavedHeaders <- loadSavedHeaders session
+  let siAccountName = credAccountName $ sessionCreds session
+      completion =
+        SigninCompletion
+          { siTag = kdTag kd
+          , siAccountName
+          , siResults = results
+          , siSavedHeaders
+          }
+  signinComplete api completion
+
+
+signinComplete :: Api -> SigninCompletion -> IO ()
+signinComplete api sc = do
+  savedHdrs <- loadSavedHeaders (apiSession api)
+  let req = withHeaders (authHeaders api savedHdrs) $ signinCompleteReq (apiEndpoints api) sc
+  resp <- callApi api req :: IO (Response (ApiResponse ()))
+  handleSigninComplete resp
+
+
+signinCompleteReq :: Endpoints -> SigninCompletion -> Request
+signinCompleteReq = mkJsonRequest signinCompleteBase signinCompleteValue
+
+
+signinCompleteValue :: SigninCompletion -> Value
+signinCompleteValue sc =
+  let Results{rClientProof, rServerProof} = siResults sc
+      toBase64 = decodeUtf8 . B64.encode
+      singleElem x = Array [String x]
+      maybeArray = maybe (Array []) singleElem
+   in asObject
+        [ ("m1", String (toBase64 rClientProof))
+        , ("m2", String (toBase64 rServerProof))
+        , ("trustTokens", maybeArray (shTrustToken (siSavedHeaders sc)))
+        , ("rememberMe", Bool True)
+        , ("accountName", String (siAccountName sc))
+        , ("c", String (siTag sc))
+        ]
+
+
+handleSigninComplete :: Response (ApiResponse ()) -> IO ()
+handleSigninComplete resp = do
+  let code = statusCode $ responseStatus resp
+  if
+    | code == 401 -> throwIO InvalidCredentials
+    | code == 403 -> throwIO AccountLocked
+    | code == 412 -> throwIO PrivacyAgreementRequired
+    | code == 409 -> pure () -- 2FA required; accountLogin will detect it
+    | code >= 400 -> throwIO $ UnexpectedResponse $ showStatusOf resp
+    | otherwise -> pure ()
+
+
+-- | Check whether the current session is still valid; returns @False@ on 401, throws on other errors.
+validate :: Api -> IO Bool
+validate api@Api{apiEndpoints} = do
+  resp <- rawRequest api (validateReq apiEndpoints)
+  let code = statusCode (responseStatus resp)
+  if
+    | code == 401 -> pure False
+    | code >= 400 -> throwIO $ UnexpectedResponse $ showStatusOf resp
+    | otherwise -> pure True
+
+
+validateReq :: Endpoints -> Request
+validateReq = withJsonRequestHeaders . withBody (encode Null) . validateBase
+
+
+-- | POST to the account-login endpoint and return the raw JSON response (used to obtain 'AccountData').
+accountLogin :: Api -> IO Value
+accountLogin api@Api{apiEndpoints = ep} = do
+  savedHdrs <- loadSavedHeaders $ apiSession api
+  let hdrs = homeHeaders ep
+  callHandlingResponse accountLoginReq (withHeaders hdrs) api savedHdrs
+
+
+accountLoginReq :: Endpoints -> SavedHeaders -> Request
+accountLoginReq = mkJsonRequest accountLoginBase accountLoginValue
+
+
+-- | Trigger a 2FA push notification to the user's trusted device.
+triggerTwoFaPush :: Api -> IO ()
+triggerTwoFaPush api@Api{apiEndpoints = ep} = do
+  savedHdrs <- loadSavedHeaders (apiSession api)
+  let req =
+        withHeaders
+          (withAcceptJson $ authHeaders api savedHdrs)
+          (toPut (extendPath (epAuth ep) "/verify/trusteddevice/securitycode"))
+  void (rawRequest api req) `catch` \(e :: IOException) ->
+    throwIO (UnexpectedResponse ("2FA push failed: " <> toS (show e)))
+
+
+-- | POST to the two-step-verification trust endpoint to mark this session as trusted.
+doTrustStep :: Api -> IO ()
+doTrustStep api@Api{apiEndpoints = ep} = do
+  savedHdrs <- loadSavedHeaders (apiSession api)
+  let req = withHeaders (withAcceptJson $ authHeaders api savedHdrs) (twoSvTrust ep)
+  resp <- rawRequest api req
+  unless (statusCode (responseStatus resp) < 400) $
+    throwIO $
+      UnexpectedResponse $
+        showStatusOf resp
+
+
+-- | Submit a 2FA code entered by the user; returns @True@ when accepted, @False@ on a 400 rejection.
+verifyTwoFaCode :: Api -> AuthCode -> IO Bool
+verifyTwoFaCode api@Api{apiEndpoints = ep} code = do
+  savedHdrs <- loadSavedHeaders (apiSession api)
+  let body = encode $ Object [("securityCode", Object [("code", String code)])]
+      req =
+        withHeaders (authHeaders api savedHdrs) $
+          withJsonRequestHeaders $
+            withBody body $
+              verifySecurityCodeReq "trusteddevice" ep
+  resp <- rawRequest api req
+  let c = statusCode (responseStatus resp)
+  if
+    | c < 400 -> pure True
+    | c == 400 -> pure False
+    | otherwise -> throwIO $ UnexpectedResponse $ showStatusOf resp
+
+
+accountLoginValue :: SavedHeaders -> Value
+accountLoginValue hs =
+  asObject
+    [ ("accountCountryCode", maybeValue String (shCountry hs))
+    , ("dsWebAuthToken", maybeValue String (shSessionToken hs))
+    , ("trustToken", String $ fromMaybe "" $ shTrustToken hs)
+    , ("extended_login", Bool True)
+    ]
+
+
+newtype ListDevicesReply = ListDevicesReply {ldrDevices :: [Setup2SADevice]}
+
+
+instance FromJSON ListDevicesReply where
+  parseJSON = withObject "ListDevicesReply" $ \o -> ListDevicesReply <$> o .: "devices"
+
+
+-- | Fetch the list of trusted devices available for legacy 2SA verification; throws when the server returns none.
+listSetupDevices :: Api -> IO (NonEmpty Setup2SADevice)
+listSetupDevices api@Api{apiEndpoints = ep} = do
+  devices <- ldrDevices <$> callRequiredHeaders api (listDevices ep)
+  maybe (throwIO (UnexpectedResponse "2SA: server returned no trusted devices")) pure (NE.nonEmpty devices)
+
+
+-- | Send a 2SA verification code to the given device.
+sendSetupVerification :: Api -> Setup2SADevice -> IO ()
+sendSetupVerification api@Api{apiSession = s, apiEndpoints = ep} device = do
+  savedHdrs <- loadSavedHeaders s
+  let req =
+        withHeaders (requiredHeaders (epWidgetKey ep) savedHdrs) $
+          withJsonRequestHeaders $
+            withBody (encode device) $
+              sendVerification ep
+  resp <- rawRequest api req
+  unless (statusCode (responseStatus resp) < 400) $ throwIO $ UnexpectedResponse $ showStatusOf resp
+
+
+-- | Submit a 2SA verification code for the given device; returns @True@ when accepted.
+validateSetupVerification :: Api -> Setup2SADevice -> AuthCode -> IO Bool
+validateSetupVerification api@Api{apiSession = s, apiEndpoints = ep} device code = do
+  savedHdrs <- loadSavedHeaders s
+  let req =
+        withHeaders (requiredHeaders (epWidgetKey ep) savedHdrs) $
+          withJsonRequestHeaders $
+            withBody (encode $ validateSetupBody device code) $
+              validateVerification ep
+  resp <- rawRequest api req
+  pure $ statusCode (responseStatus resp) < 400
diff --git a/src/Network/HStratus/Session.hs b/src/Network/HStratus/Session.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Session.hs
@@ -0,0 +1,90 @@
+{-# OPTIONS_GHC -Wno-missing-home-modules #-}
+
+{- |
+Module      : Network.HStratus.Session
+Copyright   : (c) 2025 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+
+Provides the core types for an iCloud authentication session.
+
+'Credentials' holds the account ID and password used to sign in. A 'Session'
+combines those credentials with a filesystem working directory (where cookies,
+session tokens, and account state are persisted between runs) and a per-client
+identifier. 'AccountData' carries the account information returned by the
+account-login endpoint, including the HSA version that determines which
+two-factor challenge flow applies.
+
+Use 'loadSession' to initialise a session from the local filesystem. The
+credentials file is read from @$XDG_CONFIG_HOME\/hstratus\/credentials.json@;
+other session state is created in the same directory on first use.
+
+The session value is then passed to 'Network.HStratus.Http.mkApiWith' (or
+'Network.HStratus.Http.mkApi' for the default configuration) to construct an
+'Network.HStratus.Http.Api' handle for making authenticated requests.
+-}
+module Network.HStratus.Session
+  ( -- * Credentials
+
+    {- | The account ID and password used to sign in to iCloud.
+
+    Expected to be read from
+    @$XDG_CONFIG_HOME\/hstratus\/credentials.json@ with the fields
+    @accountName@ and @password@.
+    -}
+    Credentials (..)
+
+    -- * Session
+
+    {- | Persistent data identifying a user and their local authentication state.
+
+    Holds the credentials used to authenticate, the directory where session files
+    are stored (cookies, saved headers, account data), and the per-client OAuth
+    state identifier.
+    -}
+  , Session (..)
+
+    -- * AccountData
+
+    {- | Structured account information returned by the account-login endpoint.
+
+    The 'adHsaVersion' field determines which two-factor flow applies:
+
+    * @0@ — unknown (used as a sentinel when no account data is available)
+    * @1@ — legacy two-step authentication (2SA); handled via the setup endpoint
+    * @2@ — modern two-factor authentication (2FA); handled via the auth endpoint
+    -}
+  , Webservice (..)
+  , AccountData (..)
+
+    -- * Loading a session
+
+    {- | Load a 'Session' from the local filesystem.
+
+    Reads 'Credentials' from
+    @$XDG_CONFIG_HOME\/hstratus\/credentials.json@ and initialises the
+    session working directory (creating it if absent). A per-client ID is read
+    from disk if one exists, or generated and saved for future runs.
+
+    Throws an 'IOError' if the credentials file is absent or cannot be parsed.
+    -}
+  , loadSession
+
+    -- * Saving credentials
+
+    {- | Write 'Credentials' to @$XDG_CONFIG_HOME\/hstratus\/credentials.json@,
+    creating the directory if it does not exist.
+    -}
+  , saveCredentials
+  )
+where
+
+import Network.HStratus.Internal.Session
+  ( AccountData (..)
+  , Credentials (..)
+  , Session (..)
+  , Webservice (..)
+  , loadSession
+  , saveCredentials
+  )
+
diff --git a/src/Network/HStratus/Trust.hs b/src/Network/HStratus/Trust.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/HStratus/Trust.hs
@@ -0,0 +1,92 @@
+{- |
+Module      : Network.HStratus.Trust
+Copyright   : (c) 2025 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+
+Types and utilities for handling iCloud two-factor trust challenges.
+
+After a successful SRP sign-in, iCloud may require an additional verification
+step. 'TrustData' describes the challenge: which trusted phones or devices are
+available to receive a code, and the current state of the security-code gate.
+
+Two challenge flows exist:
+
+* __2FA__ (modern, HSA version ≥ 2): the auth endpoint issues a 'TrustData'
+  challenge; the user enters a code sent to a trusted phone or device.
+
+* __2SA__ (legacy, HSA version 1): the setup endpoint lists registered
+  'Setup2SADevice' values; the user selects one to receive a code.
+
+'pleaseReadCode' and 'selectSetupDevice' are interactive prompts used as
+defaults in 'Network.HStratus.Http.login' and 'Network.HStratus.Http.complete2SA'.
+Supply your own alternatives via 'Network.HStratus.Http.loginWith' and
+'Network.HStratus.Http.complete2SAWith' for testing or automation.
+-}
+module Network.HStratus.Trust
+  ( -- * Two-factor challenge data
+
+    {- | The two-factor challenge data returned by the auth endpoint after SRP sign-in.
+
+    Describes which trusted contacts are available to receive a verification code
+    ('tdList'), the current state of the security-code gate ('tdSecurityCode'),
+    and whether any trusted devices are registered ('tdNoTrustedDevices').
+    -}
+    TrustData (..)
+    -- | Information about a trusted phone number.
+  , TrustedPhone (..)
+
+    -- * Legacy two-step device
+
+    {- | A 2SA device from the setup endpoint.
+
+    Stored as the raw JSON object so the entire dict can be echoed back to
+    @sendVerificationCode@ and augmented for @validateVerificationCode@.
+    -}
+  , Setup2SADevice (..)
+    {- | Extract a human-readable label from a 'Setup2SADevice', preferring
+    @phoneNumber@ then @name@.
+    -}
+  , setup2SADeviceLabel
+
+    -- * Interactive prompts
+
+    {- | Interactively prompt the user to enter the verification code sent to
+    their trusted phone or device. The first argument is the expected code
+    length, used to make the prompt more specific (e.g. @"6-digit"@).
+
+    Used as the default code-reading action in 'Network.HStratus.Http.login'.
+    Supply an alternative via 'Network.HStratus.Http.loginWith' for testing or
+    automation.
+    -}
+  , pleaseReadCode
+    {- | Interactively prompt the user to choose between device push and SMS
+    for HSA2 2FA.
+
+    If no trusted devices are registered ('tdNoTrustedDevices' is @True@), the
+    first trusted phone is selected automatically. Otherwise, the user is
+    prompted to press Enter for device push or enter a number to receive an SMS
+    code.
+    -}
+  , selectTwoFaPhone
+    {- | Interactively prompt the user to select a device from a list of 2SA
+    setup devices.
+
+    Used as the default device-selection action in
+    'Network.HStratus.Http.complete2SA'. Supply an alternative via
+    'Network.HStratus.Http.complete2SAWith' for testing or automation.
+    -}
+  , selectSetupDevice
+  )
+where
+
+import Network.HStratus.Internal.Trust
+  ( Setup2SADevice (..)
+  , TrustData (..)
+  , TrustedPhone (..)
+  , pleaseReadCode
+  , selectSetupDevice
+  , selectTwoFaPhone
+  , setup2SADeviceLabel
+  )
+
diff --git a/test/HStratus/ApiLoggerSpec.hs b/test/HStratus/ApiLoggerSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/ApiLoggerSpec.hs
@@ -0,0 +1,122 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module HStratus.ApiLoggerSpec (spec) where
+
+import Data.Aeson (Value, decode)
+import qualified Data.ByteString.Char8 as BS8
+import qualified Data.ByteString.Lazy.Char8 as LBS8
+import Data.List (isPrefixOf)
+import HStratus.Mock (defaultScenario, withMockApp)
+import Network.HStratus.Http (ApiLogger, fileLogger, login, mkApiWith, redactingLogger, withLogger)
+import Network.HStratus.Http.Endpoints (Endpoints (..))
+import Network.HStratus.Session (Credentials (..), Session (..))
+import Network.HTTP.Client (Request (..), defaultManagerSettings, defaultRequest, newManager)
+import Network.HTTP.Types (methodPost)
+import System.FilePath ((</>))
+import System.IO (Handle, IOMode (..), withFile)
+import System.IO.Temp (withSystemTempDirectory)
+import Test.Hspec (Spec, describe, it, shouldContain, shouldNotBe, shouldNotContain, shouldSatisfy)
+
+
+spec :: Spec
+spec = describe "Network.HStratus.Http.fileLogger" $ do
+  it "writes a log entry for each HTTP request during login" $
+    withSystemTempDirectory "icloud-auth-log" $ \tmpDir -> do
+      let logPath = tmpDir </> "requests.log"
+      withLoginLog tmpDir logPath $ \contents ->
+        filter (== "---") (lines contents) `shouldSatisfy` (not . null)
+
+  it "each entry contains the HTTP method, URI and response status" $
+    withSystemTempDirectory "icloud-auth-log" $ \tmpDir -> do
+      let logPath = tmpDir </> "requests.log"
+      withLoginLog tmpDir logPath $ \contents -> do
+        contents `shouldContain` "POST"
+        contents `shouldContain` "signin/init"
+        contents `shouldContain` "200"
+
+  it "response bodies are valid JSON" $
+    withSystemTempDirectory "icloud-auth-log" $ \tmpDir -> do
+      let logPath = tmpDir </> "requests.log"
+      withLoginLog tmpDir logPath $ \contents -> do
+        let body = firstBody contents
+        (decode (LBS8.pack body) :: Maybe Value) `shouldNotBe` Nothing
+
+  describe "redactingLogger" $ do
+    it "replaces sensitive header values with <redacted>" $
+      withSystemTempDirectory "icloud-auth-redact" $ \tmpDir -> do
+        let logPath = tmpDir </> "requests.log"
+        withLoginLogUsing redactingLogger tmpDir logPath $ \contents ->
+          contents `shouldContain` "<redacted>"
+    it "preserves the method, URI and status line" $
+      withSystemTempDirectory "icloud-auth-redact" $ \tmpDir -> do
+        let logPath = tmpDir </> "requests.log"
+        withLoginLogUsing redactingLogger tmpDir logPath $ \contents -> do
+          contents `shouldContain` "POST"
+          contents `shouldContain` "signin/init"
+          contents `shouldContain` "200"
+    it "does not write raw Set-Cookie values" $
+      withSystemTempDirectory "icloud-auth-redact" $ \tmpDir -> do
+        let logPath = tmpDir </> "requests.log"
+        withLoginLogUsing fileLogger tmpDir logPath $ \verboseContents ->
+          withLoginLogUsing redactingLogger tmpDir (logPath <> ".redacted") $ \redactedContents -> do
+            let cookieLines = filter ("Set-Cookie:" `isPrefixOf`) (lines verboseContents)
+            case cookieLines of
+              [] -> pure ()
+              (firstLine : _) -> redactedContents `shouldNotContain` drop (length ("Set-Cookie: " :: String)) firstLine
+
+
+{- | Extract the body of the first log entry.
+Format: summary line, request headers, blank line, response headers, blank line, body, "---".
+-}
+firstBody :: String -> String
+firstBody contents =
+  let skipSection = drop 1 . dropWhile (not . null)
+      bodyLines = takeWhile (/= "---") $ skipSection $ skipSection $ drop 1 (lines contents)
+   in unlines bodyLines
+
+
+withLoginLog :: FilePath -> FilePath -> (String -> IO a) -> IO a
+withLoginLog = withLoginLogUsing fileLogger
+
+
+withLoginLogUsing :: (Handle -> ApiLogger) -> FilePath -> FilePath -> (String -> IO a) -> IO a
+withLoginLogUsing mkLogger tmpDir logPath action = do
+  let sessionDir = tmpDir </> "session"
+  withFile logPath WriteMode $ \logHandle ->
+    withMockApp defaultScenario $ \serverPort -> do
+      mgr <- newManager defaultManagerSettings
+      api <-
+        withLogger (mkLogger logHandle)
+          <$> mkApiWith (testSession sessionDir) (testEndpoints serverPort) mgr
+      _ <- login api
+      pure ()
+  contents <- readFile logPath
+  action contents
+
+
+testSession :: FilePath -> Session
+testSession topDir =
+  Session
+    { sessionCreds = Credentials "alice@example.com" "password123"
+    , sessionTopDir = topDir
+    , sessionClientId = "test-client-id"
+    }
+
+
+testEndpoints :: Int -> Endpoints
+testEndpoints serverPort =
+  Endpoints
+    { epHome = "http://127.0.0.1:" <> BS8.pack (show serverPort)
+    , epAuth = mockReq "/appleauth/auth"
+    , epSetup = mockReq "/setup/ws/1"
+    , epWidgetKey = "test-widget-key"
+    }
+ where
+  mockReq reqPath =
+    defaultRequest
+      { host = "127.0.0.1"
+      , port = serverPort
+      , secure = False
+      , method = methodPost
+      , path = reqPath
+      }
diff --git a/test/HStratus/Examples.hs b/test/HStratus/Examples.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Examples.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Examples
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Shared example data used across hstratus-auth test suites.
+-}
+module HStratus.Examples
+  ( byteStrings
+  , wordz
+  , errorKeys
+  , codeKeys
+  )
+where
+
+import Data.ByteString (ByteString)
+import Data.String.Conv (toS)
+import Data.Text (Text)
+
+
+byteStrings :: [ByteString]
+byteStrings =
+  [ "Good"
+  , "King"
+  , "Wenceslas"
+  , "looked"
+  , "out"
+  , "on"
+  , "feast"
+  , "of"
+  , "stephen"
+  , "when"
+  , "snow"
+  , "lay"
+  , "round"
+  , "about"
+  , "bright"
+  , "crisp"
+  , "even"
+  ]
+
+
+wordz :: [Text]
+wordz = map toS byteStrings
+
+
+errorKeys :: [Text]
+errorKeys = ["errorMessage", "reason", "errorReason", "error"]
+
+
+codeKeys :: [Text]
+codeKeys = ["errorCode", "serverErrorCode"]
diff --git a/test/HStratus/Http/CliSpec.hs b/test/HStratus/Http/CliSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Http/CliSpec.hs
@@ -0,0 +1,54 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Http.CliSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the CLI options parser in 'Network.HStratus.Http.Cli'.
+-}
+module HStratus.Http.CliSpec (spec) where
+
+import Network.HStratus.Http.Cli (CommonOpts (..), commonOptsParser)
+import Options.Applicative (ParserResult (..), defaultPrefs, execParserPure, fullDesc, info, renderFailure)
+import Test.Hspec
+import Test.Hspec.Benri (endsRight)
+
+
+defaultOpts :: CommonOpts
+defaultOpts = CommonOpts False False Nothing False False
+
+
+spec :: Spec
+spec = describe "Network.HStratus.Http.Cli.commonOptsParser" $ do
+  it "defaults all flags to False with no log file" $
+    pure (parseOpts []) `endsRight` defaultOpts
+
+  it "sets optChina when --china is given" $
+    fmap optChina (parseOpts ["--china"]) `shouldBe` Right True
+
+  it "sets optLog when --log is given" $
+    fmap optLog (parseOpts ["--log"]) `shouldBe` Right True
+
+  it "sets optLogFile when --log-file is given" $
+    fmap optLogFile (parseOpts ["--log-file", "/tmp/test.log"])
+      `shouldBe` Right (Just "/tmp/test.log")
+
+  it "sets optLogBodies when --log-bodies is given" $
+    fmap optLogBodies (parseOpts ["--log-bodies"]) `shouldBe` Right True
+
+  it "sets optRedact when --redact is given" $
+    fmap optRedact (parseOpts ["--redact"]) `shouldBe` Right True
+
+  it "accepts multiple flags together" $
+    pure (parseOpts ["--log", "--redact", "--china"])
+      `endsRight` defaultOpts{optChina = True, optLog = True, optRedact = True}
+
+
+parseOpts :: [String] -> Either String CommonOpts
+parseOpts args =
+  case execParserPure defaultPrefs (info commonOptsParser fullDesc) args of
+    Success opts -> Right opts
+    Failure failure -> Left (fst (renderFailure failure "test"))
+    CompletionInvoked _ -> Left "completion invoked"
diff --git a/test/HStratus/Http/EndpointsSpec.hs b/test/HStratus/Http/EndpointsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Http/EndpointsSpec.hs
@@ -0,0 +1,44 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Http.EndpointsSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the iCloud endpoint builders in 'Network.HStratus.Internal.Endpoints'.
+-}
+module HStratus.Http.EndpointsSpec (spec) where
+
+import qualified Data.Map.Strict as Map
+import Network.HStratus.Internal.Endpoints (Realm (..), lookupWebservice, realmEndpoints, signinCompleteBase)
+import Network.HStratus.Internal.HttpErrors (AuthError (..))
+import Network.HStratus.Internal.Session (Webservice (..))
+import Network.HTTP.Client (Request (..))
+import Test.Hspec (Spec, describe, it, shouldBe, shouldThrow)
+
+
+spec :: Spec
+spec = describe "Network.HStratus.Internal.Endpoints" $ do
+  it "signinCompleteBase queryString has no leading ?" $
+    queryString (signinCompleteBase (realmEndpoints Usual)) `shouldBe` "isRememberMeEnabled=true"
+
+  describe "lookupWebservice" $ do
+    it "resolves an active service to a Request" $ do
+      let ws = Map.fromList [("findme", Webservice "https://p01-fmipweb.icloud.com" (Just "active"))]
+      req <- lookupWebservice "findme" ws
+      host req `shouldBe` "p01-fmipweb.icloud.com"
+
+    it "resolves a service with no status to a Request" $ do
+      let ws = Map.fromList [("findme", Webservice "https://p01-fmipweb.icloud.com" Nothing)]
+      req <- lookupWebservice "findme" ws
+      host req `shouldBe` "p01-fmipweb.icloud.com"
+
+    it "throws WebserviceNotFound for an absent key" $
+      lookupWebservice "missing" Map.empty
+        `shouldThrow` (== WebserviceNotFound "missing")
+
+    it "throws WebserviceNotFound for an inactive service" $ do
+      let ws = Map.fromList [("findme", Webservice "https://p01-fmipweb.icloud.com" (Just "inactive"))]
+      lookupWebservice "findme" ws
+        `shouldThrow` (== WebserviceNotFound "findme")
diff --git a/test/HStratus/Http/ErrorsSpec.hs b/test/HStratus/Http/ErrorsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Http/ErrorsSpec.hs
@@ -0,0 +1,131 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module HStratus.Http.ErrorsSpec
+  ( spec
+  )
+where
+
+import Control.Exception (throwIO, try)
+import Data.Aeson (Value (..), decode, encode, object)
+import Data.Aeson.Key (fromText)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import Data.Maybe (catMaybes)
+import Data.Text (Text)
+import qualified HStratus.Examples as Examples
+import Network.HStratus.Internal.HttpErrors
+  ( ApiError (..)
+  , ApiResponse (..)
+  , AuthError (..)
+  , extractOr
+  )
+import Test.Hspec (Spec, context, describe, it, shouldBe, shouldReturn)
+import Test.Hspec.Benri (endsLeft, endsRight)
+import Test.QuickCheck
+  ( Gen
+  , Property
+  , elements
+  , forAll
+  , frequency
+  )
+
+
+spec :: Spec
+spec = describe "module Network.HStratus.Http.Error" $ do
+  apiErrorSpec
+  authErrorSpec
+
+
+apiErrorSpec :: Spec
+apiErrorSpec = describe "ApiError" $ do
+  context "parsing it from JSON" $ do
+    it "should succeed" prop_parseJSONApiError
+
+
+prop_parseJSONApiError :: Property
+prop_parseJSONApiError = forAll genApiErrorWithJsonEncoding $ \(encoded, ae) ->
+  decode (BS.fromStrict encoded) == Just ae
+
+
+genApiErrorWithJsonEncoding :: Gen (ByteString, ApiError)
+genApiErrorWithJsonEncoding = do
+  reasonKV <- genKeyValue $ elements Examples.errorKeys
+  mbCodeKV <- genKeyValueMb $ elements Examples.codeKeys
+  let ae =
+        ApiError
+          { aeReason = snd reasonKV
+          , aeCode = snd <$> mbCodeKV
+          }
+      asKV (x, y) = (fromText x, String y)
+      objectParts = catMaybes [Just reasonKV, mbCodeKV]
+      encoded = BS.toStrict $ encode $ object $ map asKV objectParts
+  pure (encoded, ae)
+
+
+{- |
+generate a value or Nothing as the value of field
+when there is a value, generate the value of the key
+-}
+genKeyValueMb :: Gen Text -> Gen (Maybe (Text, Text))
+genKeyValueMb keyGen = do
+  valueMb <-
+    frequency
+      [ (1, Just <$> elements Examples.wordz)
+      , (2, pure Nothing)
+      ]
+  case valueMb of
+    Nothing -> pure Nothing
+    Just x -> do
+      key <- keyGen
+      pure (Just (key, x))
+
+
+genKeyValue :: Gen Text -> Gen (Text, Text)
+genKeyValue keyGen = do
+  value <- elements Examples.wordz
+  key <- keyGen
+  pure (key, value)
+
+
+authErrorSpec :: Spec
+authErrorSpec = describe "AuthError" $ do
+  context "is catchable with try @AuthError" $ do
+    it "catches InvalidCredentials" $
+      catchAuthError InvalidCredentials `endsLeft` InvalidCredentials
+    it "catches AccountLocked" $
+      catchAuthError AccountLocked `endsLeft` AccountLocked
+    it "catches PrivacyAgreementRequired" $
+      catchAuthError PrivacyAgreementRequired `endsLeft` PrivacyAgreementRequired
+    it "catches ServiceError" $
+      catchAuthError (ServiceError "reason" (Just "code")) `endsLeft` ServiceError "reason" (Just "code")
+    it "catches UnexpectedResponse" $
+      catchAuthError (UnexpectedResponse "oops") `endsLeft` UnexpectedResponse "oops"
+
+  context "extractOr on a Failed ApiResponse" $ do
+    it "throws ServiceError with the ApiError reason and code" $
+      catchAuthError' (extractOr (Failed (ApiError "bad" (Just "E1"))))
+        `endsRight` ServiceError "bad" (Just "E1")
+
+  context "extractOr on a Succeeded ApiResponse" $ do
+    it "returns the wrapped value" $
+      extractOr (Succeeded (42 :: Int)) `shouldReturn` 42
+
+  context "ApiResponse FromJSON" $ do
+    it "parses a success body as Succeeded" $
+      (decode "{\"length\":6}" :: Maybe (ApiResponse Value))
+        `shouldBe` Just (Succeeded (object [("length", Number 6)]))
+    it "parses an error body as Failed" $
+      (decode "{\"errorMessage\":\"oops\"}" :: Maybe (ApiResponse Value))
+        `shouldBe` Just (Failed (ApiError "oops" Nothing))
+
+
+catchAuthError :: AuthError -> IO (Either AuthError AuthError)
+catchAuthError e = try (throwIO e)
+
+
+catchAuthError' :: IO a -> IO (Either AuthError AuthError)
+catchAuthError' action =
+  try action >>= \case
+    Left e -> pure (Right e)
+    Right _ -> pure (Left (UnexpectedResponse "expected AuthError but got success"))
diff --git a/test/HStratus/Http/HeadersSpec.hs b/test/HStratus/Http/HeadersSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Http/HeadersSpec.hs
@@ -0,0 +1,273 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Http.HeadersSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for HTTP header handling in the iCloud authentication flow.
+-}
+module HStratus.Http.HeadersSpec (spec) where
+
+import Data.Aeson (decode, encodeFile)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Char8 as BS8
+import Data.IORef (readIORef)
+import Data.List (find)
+import Data.Maybe (fromJust)
+import HStratus.Mock (Scenario (..), defaultScenario, withMockAppCapturing)
+import Network.HStratus.Http (fetchTrustData, login, loginWith, mkApiWith, requestSmsCode, verifySmsCode)
+import Network.HStratus.Http.Endpoints (Endpoints (..))
+import Network.HStratus.Internal.Session (SavedHeaders (..), savedHeadersPath)
+import Network.HStratus.Session (Credentials (..), Session (..))
+import Network.HStratus.Trust (Setup2SADevice, TrustedPhone (..))
+import Network.HTTP.Client (Request (..), defaultManagerSettings, defaultRequest, newManager)
+import Network.HTTP.Types (RequestHeaders, hAccept, hContentType, methodPost)
+import System.IO.Temp (withSystemTempDirectory)
+import System.Posix.Files (setFileMode)
+import Test.Hspec (Spec, describe, it, shouldSatisfy)
+
+
+spec :: Spec
+spec = describe "Network.HStratus.Http request headers" $ do
+  it "signin/init sends Content-Type and Accept: application/json" $
+    withCapturedLogin (\_ -> pure ()) $ \captured ->
+      headersFor "/appleauth/auth/signin/init" captured
+        `shouldSatisfy` hasJsonContentHeaders
+
+  it "signin/complete sends Content-Type and Accept: application/json" $
+    withCapturedLogin (\_ -> pure ()) $ \captured ->
+      headersFor "/appleauth/auth/signin/complete" captured
+        `shouldSatisfy` hasJsonContentHeaders
+
+  it "2sv/trust sends Accept: application/json" $
+    withCapturedTwoFa $ \captured ->
+      headersFor "/appleauth/auth/2sv/trust" captured
+        `shouldSatisfy` hasJsonAccept
+
+  it "validate sends Content-Type and Accept: application/json" $
+    withCapturedLogin writeSavedHeaders $ \captured ->
+      headersFor "/setup/ws/1/validate" captured
+        `shouldSatisfy` hasJsonContentHeaders
+
+  it "accountLogin sends Content-Type and Accept: application/json" $
+    withCapturedLogin (\_ -> pure ()) $ \captured ->
+      headersFor "/setup/ws/1/accountLogin" captured
+        `shouldSatisfy` hasJsonContentHeaders
+
+  it "accountLogin sends Origin and Referer" $
+    withCapturedLogin (\_ -> pure ()) $ \captured ->
+      headersFor "/setup/ws/1/accountLogin" captured
+        `shouldSatisfy` hasOrigin
+
+  it "signin/init sends X-Apple-Widget-Key" $
+    withCapturedLogin (\_ -> pure ()) $ \captured ->
+      headersFor "/appleauth/auth/signin/init" captured
+        `shouldSatisfy` hasWidgetKey
+
+  it "2sv/trust sends X-Apple-Widget-Key" $
+    withCapturedTwoFa $ \captured ->
+      headersFor "/appleauth/auth/2sv/trust" captured
+        `shouldSatisfy` hasWidgetKey
+
+  it "verify/trusteddevice/securitycode sends Content-Type, Accept: application/json, and X-Apple-Widget-Key" $
+    withCapturedTwoFa $ \captured ->
+      headersFor "/appleauth/auth/verify/trusteddevice/securitycode" captured
+        `shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs)
+
+  it "listDevices sends Accept: application/json and X-Apple-Widget-Key" $
+    withCapturedTwoSa $ \captured ->
+      headersFor "/setup/ws/1/listDevices" captured
+        `shouldSatisfy` (\hs -> hasJsonAccept hs && hasWidgetKey hs)
+
+  it "sendVerificationCode sends Content-Type, Accept: application/json, and X-Apple-Widget-Key" $
+    withCapturedTwoSa $ \captured ->
+      headersFor "/setup/ws/1/sendVerificationCode" captured
+        `shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs)
+
+  it "validateVerificationCode sends Content-Type, Accept: application/json, and X-Apple-Widget-Key" $
+    withCapturedTwoSa $ \captured ->
+      headersFor "/setup/ws/1/validateVerificationCode" captured
+        `shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs)
+
+  it "GET /appleauth/auth sends scnt, X-Apple-ID-Session-Id, and X-Apple-Widget-Key" $
+    withCapturedFetchTrustData $ \captured ->
+      headersFor "/appleauth/auth" captured
+        `shouldSatisfy` (\hs -> hasWidgetKey hs && hasScnt hs && hasSessionId hs)
+
+  it "requestSmsCode sends Content-Type, Accept, Widget-Key, scnt, and X-Apple-ID-Session-Id" $
+    withCapturedRequestSms $ \captured ->
+      headersFor "/appleauth/auth/verify/phone" captured
+        `shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs && hasScnt hs && hasSessionId hs)
+
+  it "verifySmsCode sends Content-Type, Accept, Widget-Key, scnt, and X-Apple-ID-Session-Id" $
+    withCapturedVerifySms $ \captured ->
+      headersFor "/verify/phone/securitycode" captured
+        `shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs && hasScnt hs && hasSessionId hs)
+
+
+withCapturedLogin :: (FilePath -> IO ()) -> ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
+withCapturedLogin setup action =
+  withSystemTempDirectory "icloud-auth-headers" $ \tmpDir -> do
+    setup tmpDir
+    withMockAppCapturing defaultScenario $ \serverPort capturedRef -> do
+      mgr <- newManager defaultManagerSettings
+      api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
+      _ <- login api
+      captured <- readIORef capturedRef
+      action captured
+
+
+withCapturedTwoFa :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
+withCapturedTwoFa action =
+  withSystemTempDirectory "icloud-auth-headers-2fa" $ \tmpDir ->
+    withMockAppCapturing defaultScenario{snAccountLoginNeeds2FA = 1} $ \serverPort capturedRef -> do
+      mgr <- newManager defaultManagerSettings
+      api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
+      _ <- loginWith (\_ -> pure "123456") (\_ -> pure Nothing) (\_ -> pure testDevice) api
+      captured <- readIORef capturedRef
+      action captured
+
+
+withCapturedTwoSa :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
+withCapturedTwoSa action =
+  withSystemTempDirectory "icloud-auth-headers-2sa" $ \tmpDir ->
+    withMockAppCapturing defaultScenario{snAccountLoginNeeds2SA = True} $ \serverPort capturedRef -> do
+      mgr <- newManager defaultManagerSettings
+      api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
+      _ <- loginWith (\_ -> pure "0") (\_ -> pure Nothing) (\_ -> pure testDevice) api
+      captured <- readIORef capturedRef
+      action captured
+
+
+withCapturedFetchTrustData :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
+withCapturedFetchTrustData action =
+  withSystemTempDirectory "icloud-auth-headers-trustdata" $ \tmpDir -> do
+    writeSavedHeadersWithSession tmpDir
+    withMockAppCapturing defaultScenario $ \serverPort capturedRef -> do
+      mgr <- newManager defaultManagerSettings
+      api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
+      _ <- fetchTrustData api
+      captured <- readIORef capturedRef
+      action captured
+
+
+writeSavedHeaders :: FilePath -> IO ()
+writeSavedHeaders tmpDir = do
+  let creds = Credentials "alice@example.com" "password123"
+      shPath = savedHeadersPath tmpDir creds
+      hdrs = SavedHeaders Nothing Nothing (Just "test-token") Nothing Nothing
+  encodeFile shPath hdrs
+  setFileMode shPath 0o600
+
+
+writeSavedHeadersWithSession :: FilePath -> IO ()
+writeSavedHeadersWithSession tmpDir = do
+  let creds = Credentials "alice@example.com" "password123"
+      shPath = savedHeadersPath tmpDir creds
+      hdrs = SavedHeaders Nothing (Just "test-session-id") Nothing Nothing (Just "test-scnt")
+  encodeFile shPath hdrs
+  setFileMode shPath 0o600
+
+
+withCapturedRequestSms :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
+withCapturedRequestSms action =
+  withSystemTempDirectory "icloud-auth-headers-req-sms" $ \tmpDir -> do
+    writeSavedHeadersWithSession tmpDir
+    withMockAppCapturing defaultScenario $ \serverPort capturedRef -> do
+      mgr <- newManager defaultManagerSettings
+      api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
+      requestSmsCode api testPhone
+      captured <- readIORef capturedRef
+      action captured
+
+
+withCapturedVerifySms :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
+withCapturedVerifySms action =
+  withSystemTempDirectory "icloud-auth-headers-verify-sms" $ \tmpDir -> do
+    writeSavedHeadersWithSession tmpDir
+    withMockAppCapturing defaultScenario $ \serverPort capturedRef -> do
+      mgr <- newManager defaultManagerSettings
+      api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
+      _ <- verifySmsCode api testPhone "654321"
+      captured <- readIORef capturedRef
+      action captured
+
+
+testPhone :: TrustedPhone
+testPhone = TrustedPhone 1 "+81 test" (Just "sms")
+
+
+headersFor :: ByteString -> [(ByteString, RequestHeaders)] -> Maybe RequestHeaders
+headersFor pathFragment captured =
+  fmap snd $ find (\(p, _) -> pathFragment `BS8.isSuffixOf` p) captured
+
+
+hasJsonAccept :: Maybe RequestHeaders -> Bool
+hasJsonAccept Nothing = False
+hasJsonAccept (Just hs) = any (\(n, v) -> n == hAccept && v == "application/json") hs
+
+
+hasJsonContentType :: Maybe RequestHeaders -> Bool
+hasJsonContentType Nothing = False
+hasJsonContentType (Just hs) = any (\(n, v) -> n == hContentType && v == "application/json") hs
+
+
+hasWidgetKey :: Maybe RequestHeaders -> Bool
+hasWidgetKey Nothing = False
+hasWidgetKey (Just hs) = any (\(n, _) -> n == "X-Apple-Widget-Key") hs
+
+
+hasOrigin :: Maybe RequestHeaders -> Bool
+hasOrigin Nothing = False
+hasOrigin (Just hs) = any (\(n, _) -> n == "Origin") hs
+
+
+hasJsonContentHeaders :: Maybe RequestHeaders -> Bool
+hasJsonContentHeaders hs = hasJsonAccept hs && hasJsonContentType hs
+
+
+hasScnt :: Maybe RequestHeaders -> Bool
+hasScnt Nothing = False
+hasScnt (Just hs) = any (\(n, _) -> n == "scnt") hs
+
+
+hasSessionId :: Maybe RequestHeaders -> Bool
+hasSessionId Nothing = False
+hasSessionId (Just hs) = any (\(n, _) -> n == "X-Apple-ID-Session-Id") hs
+
+
+testSession :: FilePath -> Session
+testSession topDir =
+  Session
+    { sessionCreds = Credentials "alice@example.com" "password123"
+    , sessionTopDir = topDir
+    , sessionClientId = "test-client-id"
+    }
+
+
+testEndpoints :: Int -> Endpoints
+testEndpoints serverPort =
+  Endpoints
+    { epHome = "http://127.0.0.1:" <> BS8.pack (show serverPort)
+    , epAuth = mockReq "/appleauth/auth"
+    , epSetup = mockReq "/setup/ws/1"
+    , epWidgetKey = "test-widget-key"
+    }
+ where
+  mockReq reqPath =
+    defaultRequest
+      { host = "127.0.0.1"
+      , port = serverPort
+      , secure = False
+      , method = methodPost
+      , path = reqPath
+      }
+
+
+testDevice :: Setup2SADevice
+testDevice =
+  fromJust $
+    decode
+      "{\"deviceType\":\"SMS\",\"areaCode\":\"\",\"phoneNumber\":\"*******58\",\"deviceId\":\"1\"}"
diff --git a/test/HStratus/HttpMockSpec.hs b/test/HStratus/HttpMockSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/HttpMockSpec.hs
@@ -0,0 +1,223 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.HttpMockSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the iCloud HTTP client using a mock server.
+-}
+module HStratus.HttpMockSpec (spec) where
+
+import Control.Exception (try)
+import Data.Aeson (decode, encodeFile)
+import Data.Bits ((.&.))
+import qualified Data.ByteString.Char8 as BS8
+import Data.IORef (newIORef, readIORef, writeIORef)
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.Maybe (fromJust)
+import qualified Data.Text as Text
+import HStratus.Mock (Scenario (..), SrpOutcome (..), defaultScenario, withMockApp, withMockAppCapturing)
+import Network.HStratus.Http
+  ( Api
+  , AuthState (..)
+  , complete2SAWith
+  , login
+  , loginWith
+  , mkApiWith
+  )
+import Network.HStratus.Http.Endpoints (Endpoints (..))
+import Network.HStratus.Internal.HttpErrors (AuthError (..))
+import Network.HStratus.Internal.Session (SavedHeaders (..), cookiePath, savedHeadersPath)
+import Network.HStratus.Session (Credentials (..), Session (..))
+import Network.HStratus.Trust (Setup2SADevice, TrustedPhone (..))
+import Network.HTTP.Client (Request (..), defaultManagerSettings, defaultRequest, newManager)
+import Network.HTTP.Types (methodPost)
+import System.FilePath ((</>))
+import System.IO.Temp (withSystemTempDirectory)
+import System.Posix.Files (fileMode, getFileStatus, setFileMode)
+import Test.Hspec (Spec, describe, it, shouldBe, shouldReturn, shouldSatisfy)
+
+
+spec :: Spec
+spec = describe "Network.HStratus.Http.login" $ do
+  it "returns Authenticated on fresh login" $
+    loginShouldAuthenticate defaultScenario
+
+  it "creates the session directory when absent then returns Authenticated" $
+    withSystemTempDirectory "icloud-auth-mock" $ \tmpDir ->
+      withMockApi (tmpDir </> "session") defaultScenario $ \api -> do
+        isAuthenticated <$> login api `shouldReturn` True
+
+  it "returns Authenticated when saved headers are valid" $
+    withSystemTempDirectory "icloud-auth-mock" $ \tmpDir -> do
+      writeSavedHeaders tmpDir
+      withMockApi tmpDir defaultScenario $ \api -> do
+        isAuthenticated <$> login api `shouldReturn` True
+
+  it "falls through to fresh login when validate returns 401" $
+    withSystemTempDirectory "icloud-auth-mock" $ \tmpDir -> do
+      writeSavedHeaders tmpDir
+      withMockApi tmpDir defaultScenario{snValidate = False} $ \api -> do
+        isAuthenticated <$> login api `shouldReturn` True
+
+  it "completes 2FA automatically when accountLogin requires 2FA" $
+    withFreshMockApi "icloud-auth-mock" defaultScenario{snAccountLoginNeeds2FA = 1} $ \api -> do
+      isAuthenticated <$> loginWith (\_ -> pure "123456") (\_ -> pure Nothing) (\_ -> pure testDevice) api `shouldReturn` True
+
+  it "complete2SA returns Authenticated after 2SA challenge" $
+    withFreshMockApi "icloud-auth-2sa" defaultScenario $ \api -> do
+      result <- complete2SAWith (\_ -> pure testDevice) (pure "0") api (testDevice :| [])
+      isAuthenticated result `shouldBe` True
+
+  it "completes 2SA automatically when account login signals 2SA required" $
+    withFreshMockApi "icloud-auth-2sa-login" defaultScenario{snAccountLoginNeeds2SA = True} $ \api -> do
+      isAuthenticated <$> loginWith (\_ -> pure "0") (\_ -> pure Nothing) (\_ -> pure testDevice) api `shouldReturn` True
+
+  it "throws UnexpectedResponse with HTTP status when error response has no body" $
+    withFreshMockApi "icloud-auth-empty-err" defaultScenario{snSrpCompleteEmptyError = True} $ \api -> do
+      let throwsBadRequest (Left (UnexpectedResponse msg)) = "bad request" `Text.isPrefixOf` msg
+          throwsBadRequest _otherwise = False
+      result <- try (login api) :: IO (Either AuthError AuthState)
+      result `shouldSatisfy` throwsBadRequest
+
+  it "complete2SA retries when the first verification code is wrong" $ do
+    codeRef <- newIORef ["wrongcode", "0"]
+    let readCode = do
+          codes <- readIORef codeRef
+          case codes of
+            [] -> fail "no more codes"
+            (c : rest) -> writeIORef codeRef rest >> pure c
+    withFreshMockApi "icloud-auth-2sa-retry" defaultScenario{snValidateCodeFails = True} $ \api -> do
+      result <- complete2SAWith (\_ -> pure testDevice) readCode api (testDevice :| [])
+      isAuthenticated result `shouldBe` True
+
+  it "completes 2FA via SMS when phone selector returns a phone" $
+    withFreshMockApi "icloud-auth-2fa-sms" defaultScenario{snAccountLoginNeeds2FA = 1} $ \api -> do
+      isAuthenticated <$> loginWith (\_ -> pure "654321") (\_ -> pure (Just testPhone)) (\_ -> pure testDevice) api
+        `shouldReturn` True
+
+  it "calls GET /appleauth/auth when accountLogin requires 2FA" $
+    withSystemTempDirectory "icloud-auth-fetches-trust" $ \tmpDir -> do
+      let scenario = defaultScenario{snSrpOutcome = SrpNeeds2FA, snAccountLoginNeeds2FA = 1}
+      withMockAppCapturing scenario $ \serverPort capturedRef -> do
+        mgr <- newManager defaultManagerSettings
+        api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
+        _ <- loginWith (\_ -> pure "123456") (\_ -> pure Nothing) (\_ -> pure testDevice) api
+        captured <- readIORef capturedRef
+        map fst captured `shouldSatisfy` elem "/appleauth/auth"
+
+  it "completes 2FA via device push after retrying when the first code is wrong" $ do
+    codeRef <- newIORef ["wrongcode", "123456"]
+    let readCode = do
+          codes <- readIORef codeRef
+          case codes of
+            [] -> fail "no more codes"
+            (c : rest) -> writeIORef codeRef rest >> pure c
+    withFreshMockApi "icloud-auth-2fa-retry" defaultScenario{snAccountLoginNeeds2FA = 1, snVerifyDeviceCodeFails = True} $ \api ->
+      isAuthenticated <$> loginWith (const readCode) (\_ -> pure Nothing) (\_ -> pure testDevice) api
+        `shouldReturn` True
+
+  it "retries signin/init and completes login when first response is 421" $
+    loginShouldAuthenticate defaultScenario{snSrpInitReturnsRetryCode = Just 421}
+
+  it "retries signin/init and completes login when first response is 450" $
+    loginShouldAuthenticate defaultScenario{snSrpInitReturnsRetryCode = Just 450}
+
+  it "retries signin/init and completes login when first response is 500" $
+    loginShouldAuthenticate defaultScenario{snSrpInitReturnsRetryCode = Just 500}
+
+  it "throws TwoFactorLocked when the server signals the account is locked" $
+    withFreshMockApi "icloud-auth-2fa-locked" defaultScenario{snAccountLoginNeeds2FA = 1, snVerifyCodeLocks = True} $ \api -> do
+      result <- try (loginWith (\_ -> pure "wrongcode") (\_ -> pure Nothing) (\_ -> pure testDevice) api) :: IO (Either AuthError AuthState)
+      result `shouldSatisfy` (\case Left TwoFactorLocked -> True; _ -> False)
+
+  it "sets cookie jar mode to 0o600 after login" $
+    withSystemTempDirectory "icloud-auth-cookie-perms" $ \tmpDir ->
+      withMockApi tmpDir defaultScenario $ \api -> do
+        _ <- login api
+        let creds = Credentials "alice@example.com" "password123"
+            jarPath = cookiePath tmpDir creds
+        shouldHaveMode600 jarPath
+
+
+shouldHaveMode600 :: FilePath -> IO ()
+shouldHaveMode600 filePath = do
+  mode <- fileMode <$> getFileStatus filePath
+  (mode .&. 0o777) `shouldBe` 0o600
+
+
+withMockApi :: FilePath -> Scenario -> (Api -> IO a) -> IO a
+withMockApi tmpDir scenario action =
+  withMockApp scenario $ \serverPort -> do
+    mgr <- newManager defaultManagerSettings
+    api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
+    action api
+
+
+withFreshMockApi :: String -> Scenario -> (Api -> IO a) -> IO a
+withFreshMockApi prefix scenario action =
+  withSystemTempDirectory prefix $ \tmpDir ->
+    withMockApi tmpDir scenario action
+
+
+loginShouldAuthenticate :: Scenario -> IO ()
+loginShouldAuthenticate scenario =
+  withFreshMockApi "icloud-auth-mock" scenario $ \api -> do
+    result <- login api
+    isAuthenticated result `shouldBe` True
+
+
+testSession :: FilePath -> Session
+testSession topDir =
+  Session
+    { sessionCreds = Credentials "alice@example.com" "password123"
+    , sessionTopDir = topDir
+    , sessionClientId = "test-client-id"
+    }
+
+
+testEndpoints :: Int -> Endpoints
+testEndpoints serverPort =
+  Endpoints
+    { epHome = "http://127.0.0.1:" <> BS8.pack (show serverPort)
+    , epAuth = mockReq "/appleauth/auth"
+    , epSetup = mockReq "/setup/ws/1"
+    , epWidgetKey = "test-widget-key"
+    }
+ where
+  mockReq reqPath =
+    defaultRequest
+      { host = "127.0.0.1"
+      , port = serverPort
+      , secure = False
+      , method = methodPost
+      , path = reqPath
+      }
+
+
+writeSavedHeaders :: FilePath -> IO ()
+writeSavedHeaders tmpDir = do
+  let creds = Credentials "alice@example.com" "password123"
+      hdrsPath = savedHeadersPath tmpDir creds
+      hdrs = SavedHeaders Nothing Nothing (Just "test-token") Nothing Nothing
+  encodeFile hdrsPath hdrs
+  setFileMode hdrsPath 0o600
+
+
+isAuthenticated :: AuthState -> Bool
+isAuthenticated (Authenticated _ _) = True
+isAuthenticated _ = False
+
+
+testDevice :: Setup2SADevice
+testDevice =
+  fromJust $
+    decode
+      "{\"deviceType\":\"SMS\",\"areaCode\":\"\",\"phoneNumber\":\"*******58\",\"deviceId\":\"1\"}"
+
+
+testPhone :: TrustedPhone
+testPhone = TrustedPhone 1 "+81 test" (Just "sms")
diff --git a/test/HStratus/HttpSpec.hs b/test/HStratus/HttpSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/HttpSpec.hs
@@ -0,0 +1,178 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.HttpSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the iCloud HTTP client request and response handling.
+-}
+module HStratus.HttpSpec
+  ( spec
+  )
+where
+
+import Crypto.SRP.Hashing (KnownAlgorithm (SHA256), hashText)
+import Data.Aeson (Value (..), decode, withObject, (.:))
+import Data.Aeson.KeyMap (fromList)
+import Data.Aeson.Types (parseMaybe)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Base16 as Base16
+import Data.String.Conv (toS)
+import Data.Text (Text)
+import qualified HStratus.Examples as Examples
+import Network.HStratus.Internal.Http
+  ( PasswordProtocol (..)
+  , hCounter
+  , hCountry
+  , hSessionId
+  , hSessionToken
+  , hTrustToken
+  , needsRetry
+  , phoneCodeBody
+  , phoneTriggerBody
+  , validateSetupBody
+  )
+import Network.HStratus.Internal.Session
+  ( SavedHeaders (..)
+  , pristine
+  , updateSavedHeaders
+  )
+import Network.HStratus.Trust (Setup2SADevice (..), TrustedPhone (..))
+import Network.HTTP.Types (HeaderName)
+import Test.Hspec (Spec, context, describe, it, shouldBe)
+import Test.QuickCheck
+  ( Gen
+  , Property
+  , elements
+  , forAll
+  , forAllBlind
+  , sublistOf
+  , vectorOf
+  )
+
+
+spec :: Spec
+spec = describe "module Network.HStratus.Http" $ do
+  updateSavedHeadersSpec
+  needsRetrySpec
+  passwordProtocolSpec
+  validateSetupBodySpec
+  phoneCodeBodySpec
+  phoneTriggerBodySpec
+
+
+updateSavedHeadersSpec :: Spec
+updateSavedHeadersSpec = describe "updateSavedHeaders" $ do
+  context "using generated headers" $ do
+    it "should generated the expected value" prop_updateSavedHeaders
+
+
+prop_updateSavedHeaders :: Property
+prop_updateSavedHeaders = forAllBlind genHdrsAndExpectedSavedHeaders $ \(hdrs, f) ->
+  f $ updateSavedHeaders hdrs pristine
+
+
+genHdrsAndExpectedSavedHeaders :: Gen ([(HeaderName, ByteString)], SavedHeaders -> Bool)
+genHdrsAndExpectedSavedHeaders = do
+  checks <- sublistOf sdChecks
+  values <- vectorOf (length checks) (elements Examples.byteStrings)
+  let headers = zip (map fst checks) values
+      asPred getter want sd = Just (toS want) == getter sd
+      preds = zipWith asPred (map snd checks) values
+      combine xs sd = all ($ sd) xs
+  pure (headers, combine preds)
+
+
+sdChecks :: [(HeaderName, SavedHeaders -> Maybe Text)]
+sdChecks =
+  [ (hCountry, shCountry)
+  , (hSessionId, shSessionId)
+  , (hSessionToken, shSessionToken)
+  , (hTrustToken, shTrustToken)
+  , (hCounter, shCounter)
+  ]
+
+
+needsRetrySpec :: Spec
+needsRetrySpec = describe "needsRetry" $ do
+  it "is True for 421" $ needsRetry 421 `shouldBe` True
+  it "is True for 450" $ needsRetry 450 `shouldBe` True
+  it "is True for 500" $ needsRetry 500 `shouldBe` True
+  it "is False for 200" $ needsRetry 200 `shouldBe` False
+  it "is False for 400" $ needsRetry 400 `shouldBe` False
+
+
+passwordProtocolSpec :: Spec
+passwordProtocolSpec = describe "PasswordProtocol" $ do
+  context "parsing from JSON" $ do
+    it "parses 's2k' as New" $
+      (decode "\"s2k\"" :: Maybe PasswordProtocol) `shouldBe` Just New
+    it "parses 's2k_fo' as Old" $
+      (decode "\"s2k_fo\"" :: Maybe PasswordProtocol) `shouldBe` Just Old
+    it "fails on unknown strings" $
+      (decode "\"unknown\"" :: Maybe PasswordProtocol) `shouldBe` Nothing
+  context "key derivation" $ do
+    it "Old (Base16-encoded hash) always differs from New (raw hash)" $
+      prop_oldNewHashesDiffer
+
+
+prop_oldNewHashesDiffer :: Property
+prop_oldNewHashesDiffer = forAll (elements Examples.wordz) $ \pwd ->
+  let hashed = hashText SHA256 pwd
+   in Base16.encode hashed /= hashed
+
+
+validateSetupBodySpec :: Spec
+validateSetupBodySpec = describe "validateSetupBody" $ do
+  it "includes verificationCode from the code argument" $
+    field "verificationCode" `shouldBe` Just (String "123456")
+  it "includes trustBrowser set to True" $
+    field "trustBrowser" `shouldBe` Just (Bool True)
+  it "preserves original device fields" $
+    field "deviceId" `shouldBe` Just (String "abc")
+ where
+  device = Setup2SADevice $ fromList [("deviceId", String "abc")]
+  body = validateSetupBody device "123456"
+  field k = parseMaybe (withObject "body" (.: k)) body
+
+
+phoneCodeBodySpec :: Spec
+phoneCodeBodySpec = describe "phoneCodeBody" $ do
+  it "sets phoneNumber.id to the TrustedPhone id" $
+    phoneField verifyBody "id" `shouldBe` Just (Number 1)
+  it "sets securityCode.code to the supplied code" $
+    codeField verifyBody "code" `shouldBe` Just (String "654321")
+  it "sets mode to sms" $
+    field verifyBody "mode" `shouldBe` Just (String "sms")
+  it "sets securityCode.code to empty string when requesting SMS" $
+    codeField requestBody "code" `shouldBe` Just (String "")
+  it "defaults mode to sms when tpnPushMode is Nothing" $
+    field (phoneCodeBody noModePhone "123") "mode" `shouldBe` Just (String "sms")
+ where
+  phone = TrustedPhone 1 "+81 test" (Just "sms")
+  noModePhone = TrustedPhone 1 "+81 test" Nothing
+  verifyBody = phoneCodeBody phone "654321"
+  requestBody = phoneCodeBody phone ""
+  field b k = parseMaybe (withObject "body" (.: k)) b
+  phoneField b k = field b "phoneNumber" >>= parseMaybe (withObject "phoneNumber" (.: k))
+  codeField b k = field b "securityCode" >>= parseMaybe (withObject "securityCode" (.: k))
+
+
+phoneTriggerBodySpec :: Spec
+phoneTriggerBodySpec = describe "phoneTriggerBody" $ do
+  it "sets phoneNumber.id to the TrustedPhone id" $
+    phoneField triggerBody "id" `shouldBe` Just (Number 1)
+  it "sets mode to the tpnPushMode value" $
+    field triggerBody "mode" `shouldBe` Just (String "sms")
+  it "defaults mode to sms when tpnPushMode is Nothing" $
+    field (phoneTriggerBody noModePhone) "mode" `shouldBe` Just (String "sms")
+  it "does not include a securityCode field" $
+    field triggerBody "securityCode" `shouldBe` (Nothing :: Maybe Value)
+ where
+  phone = TrustedPhone 1 "+81 test" (Just "sms")
+  noModePhone = TrustedPhone 1 "+81 test" Nothing
+  triggerBody = phoneTriggerBody phone
+  field b k = parseMaybe (withObject "body" (.: k)) b
+  phoneField b k = field b "phoneNumber" >>= parseMaybe (withObject "phoneNumber" (.: k))
diff --git a/test/HStratus/LoginFSMSpec.hs b/test/HStratus/LoginFSMSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/LoginFSMSpec.hs
@@ -0,0 +1,294 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE TypeFamilies #-}
+
+{- |
+Module      : HStratus.LoginFSMSpec
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Tests for the iCloud login finite state machine in 'Network.HStratus.Internal.LoginFSM'.
+-}
+module HStratus.LoginFSMSpec
+  ( spec
+  )
+where
+
+import Data.List.NonEmpty (NonEmpty (..))
+import Network.HStratus.Internal.LoginFSM
+import Test.Hspec (Spec, describe, it, shouldBe)
+
+
+newtype TestState s = TestState ()
+
+
+data Script = Script
+  { scriptCreds :: !Bool
+  , scriptDir :: !Bool
+  , scriptMkDir :: !Bool
+  , scriptHasSavedSession :: !Bool
+  , scriptSessionValid :: !Bool
+  , scriptSrpInvalidKey :: !Bool
+  , scriptAcct :: !Bool
+  , scriptAcctTwoFa :: !Bool
+  , scriptTwoFa :: ![Bool]
+  , scriptTwoSa :: ![Bool]
+  , scriptNoTrustedDevices :: !Bool
+  , scriptTwoFaLocked :: !Bool
+  }
+
+
+allTrue :: Script
+allTrue =
+  Script
+    { scriptCreds = True
+    , scriptDir = True
+    , scriptMkDir = True
+    , scriptHasSavedSession = False
+    , scriptSessionValid = False
+    , scriptSrpInvalidKey = False
+    , scriptAcct = True
+    , scriptAcctTwoFa = False
+    , scriptTwoFa = [True]
+    , scriptTwoSa = [True]
+    , scriptNoTrustedDevices = False
+    , scriptTwoFaLocked = False
+    }
+
+
+popTwoFa :: TestM Bool
+popTwoFa = TestM $ \s -> case scriptTwoFa s of
+  (b : bs) -> (b, s{scriptTwoFa = bs})
+  [] -> (True, s)
+
+
+popTwoSa :: TestM Bool
+popTwoSa = TestM $ \s -> case scriptTwoSa s of
+  (b : bs) -> (b, s{scriptTwoSa = bs})
+  [] -> (True, s)
+
+
+instance LoginEvent TestM where
+  type State TestM = TestState
+
+
+  initial = pure (TestState ())
+
+
+  ratifyCreds (TestState ()) = asksScript $ \s ->
+    if scriptCreds s
+      then GotCreds (TestState ())
+      else NoCreds (TestState ())
+
+
+  ratifyArtifactDir (TestState ()) = asksScript $ \s ->
+    if scriptDir s
+      then DirPresent (TestState ())
+      else DirAbsent (TestState ())
+
+
+  mkArtifactDir (TestState ()) = asksScript $ \s ->
+    if scriptMkDir s
+      then DirMade (TestState ())
+      else NotMade (TestState ())
+
+
+  loadSession (TestState ()) = asksScript $ \s ->
+    if scriptHasSavedSession s
+      then HasPriorSession (TestState ())
+      else HasClientId (TestState ())
+
+
+  validateSession (TestState ()) = asksScript $ \s ->
+    if scriptSessionValid s
+      then SessionStillValid (TestState ())
+      else SessionStale (TestState ())
+
+
+  srpInit (TestState ()) = pure (TestState ())
+
+
+  srpComplete (TestState ()) = asksScript $ \s ->
+    if scriptSrpInvalidKey s
+      then SrpCompleteInvalidKey (TestState ())
+      else SrpCompleteOk (TestState ())
+
+
+  acctLogin (TestState ()) = asksScript $ \s ->
+    if scriptAcctTwoFa s
+      then AcctLogin2FA (TestState ())
+      else
+        if scriptAcct s
+          then AcctLoginOk (TestState ())
+          else AcctLogin2SA (TestState ())
+
+
+  listTwoSaDevices (TestState ()) = pure (TestState ())
+
+
+  beginTwoFa (TestState ()) _cfg = pure (TestState ())
+
+
+  doTrust (TestState ()) = pure (TestState ())
+
+
+  verifyTwoFa (TestState ()) _cfg = do
+    result <- popTwoFa
+    if result
+      then pure $ TwoFaOk (TestState ())
+      else asksScript $ \s ->
+        if scriptTwoFaLocked s
+          then TwoFaLocked (TestState ())
+          else TwoFaRetry (TestState ())
+
+
+  beginTwoSa (TestState ()) _cfg = pure (TestState ())
+
+
+  verifyTwoSa (TestState ()) _cfg = do
+    result <- popTwoSa
+    pure $ if result then TwoSaOk (TestState ()) else TwoSaRetry (TestState ())
+
+
+data Outcome
+  = Authenticated
+  | TwoFa
+  | TwoSa
+  | HaltCreds
+  | HaltMkDir
+  | HaltSrp
+  | LockedByTwoFa
+  deriving (Eq, Show)
+
+
+outcomeOf :: LoginOutcome TestState -> Outcome
+outcomeOf = \case
+  LoginAuthenticated _ -> Authenticated
+  LoginNeedsTwoFa _ -> TwoFa
+  LoginNeedsTwoSa _ -> TwoSa
+  LoginHaltCreds _ -> HaltCreds
+  LoginHaltDir _ -> HaltMkDir
+  LoginHaltSrp _ -> HaltSrp
+  LoginHaltTwoFaLocked _ -> LockedByTwoFa
+
+
+completionOutcomeOf :: CompletionOutcome TestState -> Outcome
+completionOutcomeOf = \case
+  CompletionAuthenticated _ -> Authenticated
+  CompletionNeedsTwoFa _ -> TwoFa
+  CompletionNeedsTwoSa _ -> TwoSa
+  CompletionTwoFaLocked _ -> LockedByTwoFa
+
+
+runScript :: Script -> Outcome
+runScript s = outcomeOf $ runTestM loginProcess s
+
+
+runTwoFaScript :: Script -> Outcome
+runTwoFaScript s = completionOutcomeOf $ runTestM (twoFaProcess (TestState ()) dummyTwoFaConfig) s
+
+
+runTwoSaScript :: Script -> Outcome
+runTwoSaScript s = completionOutcomeOf $ runTestM (twoSaProcess (TestState ()) dummyTwoSaConfig) s
+
+
+dummyTwoFaConfig :: TwoFaConfig
+dummyTwoFaConfig = TwoFaConfig{tfcPickPhone = \_ -> pure Nothing, tfcReadCode = \_ -> pure ""}
+
+
+dummyTwoSaConfig :: TwoSaConfig
+dummyTwoSaConfig =
+  TwoSaConfig
+    { tscPickDevice = \(d :| _) -> pure d
+    , tscReadCode = pure ""
+    }
+
+
+spec :: Spec
+spec = do
+  describe "LoginFSM.loginProcess" $ do
+    it "halts when credentials are missing" $
+      runScript (allTrue{scriptCreds = False}) `shouldBe` HaltCreds
+
+    it "halts when the artifact directory cannot be created" $
+      runScript (allTrue{scriptDir = False, scriptMkDir = False}) `shouldBe` HaltMkDir
+
+    it "halts with invalid SRP key when the server public value is bad" $
+      runScript (allTrue{scriptSrpInvalidKey = True}) `shouldBe` HaltSrp
+
+    it "reaches Authenticated on the happy path" $
+      runScript allTrue `shouldBe` Authenticated
+
+    it "produces LoginNeedsTwoFa when account login signals 2FA required" $
+      runScript (allTrue{scriptAcctTwoFa = True}) `shouldBe` TwoFa
+
+    it "reaches Requires2SA when account login signals 2SA required" $
+      runScript (allTrue{scriptAcct = False}) `shouldBe` TwoSa
+
+    it "creates the artifact directory when absent then reaches Authenticated" $
+      runScript (allTrue{scriptDir = False}) `shouldBe` Authenticated
+
+    it "returns Authenticated immediately when the saved session is still valid" $
+      runScript (allTrue{scriptHasSavedSession = True, scriptSessionValid = True}) `shouldBe` Authenticated
+
+    it "falls through to SRP when the saved session is stale" $
+      runScript (allTrue{scriptHasSavedSession = True, scriptSessionValid = False}) `shouldBe` Authenticated
+
+  describe "LoginFSM.twoFaProcess" $ do
+    it "reaches Authenticated when 2FA verification succeeds on the first attempt" $
+      runTwoFaScript allTrue `shouldBe` Authenticated
+
+    it "retries and reaches Authenticated after a failed 2FA verification" $
+      runTwoFaScript (allTrue{scriptTwoFa = [False, True]}) `shouldBe` Authenticated
+
+    it "reaches Requires2SA when account login signals 2SA required after 2FA" $
+      runTwoFaScript (allTrue{scriptAcct = False}) `shouldBe` TwoSa
+
+    it "still reaches Authenticated when noTrustedDevices is True" $
+      runTwoFaScript (allTrue{scriptNoTrustedDevices = True}) `shouldBe` Authenticated
+
+    it "halts with TwoFaLocked when the code is rejected and the server signals the account is locked" $
+      runTwoFaScript (allTrue{scriptTwoFa = [False], scriptTwoFaLocked = True}) `shouldBe` LockedByTwoFa
+
+  describe "LoginFSM.twoSaProcess" $ do
+    it "reaches Authenticated when 2SA verification succeeds on the first attempt" $
+      runTwoSaScript allTrue `shouldBe` Authenticated
+
+    it "retries and reaches Authenticated after a failed 2SA verification" $
+      runTwoSaScript (allTrue{scriptTwoSa = [False, True]}) `shouldBe` Authenticated
+
+    it "reaches Requires2SA when account login signals 2SA required after 2SA" $
+      runTwoSaScript (allTrue{scriptAcct = False}) `shouldBe` TwoSa
+
+
+newtype TestM a = TestM (Script -> (a, Script))
+
+
+instance Functor TestM where
+  fmap f (TestM m) = TestM $ \s -> let (a, s') = m s in (f a, s')
+
+
+instance Applicative TestM where
+  pure a = TestM (a,)
+  TestM mf <*> TestM ma = TestM $ \s ->
+    let (f, s') = mf s
+        (a, s'') = ma s'
+     in (f a, s'')
+
+
+instance Monad TestM where
+  return = pure
+  TestM ma >>= f = TestM $ \s ->
+    let (a, s') = ma s
+        TestM mb = f a
+     in mb s'
+
+
+runTestM :: TestM a -> Script -> a
+runTestM (TestM m) s = fst (m s)
+
+
+asksScript :: (Script -> a) -> TestM a
+asksScript f = TestM $ \s -> (f s, s)
diff --git a/test/HStratus/Mock.hs b/test/HStratus/Mock.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/Mock.hs
@@ -0,0 +1,213 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.Mock
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+A configurable mock iCloud server for hstratus-auth integration tests.
+-}
+module HStratus.Mock
+  ( Scenario (..)
+  , SrpOutcome (..)
+  , defaultScenario
+  , withMockApp
+  , withMockAppCapturing
+  )
+where
+
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Lazy as LBS
+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
+import Network.HTTP.Types (HeaderName, RequestHeaders, hContentType, mkStatus, status200, status204, status400, status401, status404, status409)
+import Network.Wai (Application, pathInfo, rawPathInfo, requestHeaders, requestMethod, responseLBS)
+import Network.Wai.Handler.Warp (testWithApplication)
+import Paths_hstratus_auth (getDataFileName)
+
+
+data SrpOutcome = SrpOk | SrpNeeds2FA
+  deriving (Eq, Show)
+
+
+data Scenario = Scenario
+  { snValidate :: Bool
+  , snSrpOutcome :: SrpOutcome
+  , snValidateCodeFails :: Bool
+  -- ^ when True, the first validateVerificationCode call returns 400
+  , snAccountLoginNeeds2SA :: Bool
+  -- ^ when True, the first accountLogin call returns a 2SA-required response
+  , snAccountLoginNeeds2FA :: Int
+  -- ^ countdown: serve login_2fa_test.json while > 0, then loginWorking
+  , snSrpCompleteEmptyError :: Bool
+  -- ^ when True, signin/complete returns 401 with no body or Content-Type
+  , snVerifyCodeLocks :: Bool
+  -- ^ when True, POST verify/trusteddevice/securitycode returns 400 and GET /appleauth/auth returns locked trust data
+  , snVerifyDeviceCodeFails :: Bool
+  -- ^ when True, the first POST verify/trusteddevice/securitycode returns 400 (non-locking retry)
+  , snSrpInitReturnsRetryCode :: Maybe Int
+  -- ^ when Just n, signin/init returns status n on the first call, then succeeds
+  }
+
+
+defaultScenario :: Scenario
+defaultScenario =
+  Scenario
+    { snValidate = True
+    , snSrpOutcome = SrpOk
+    , snValidateCodeFails = False
+    , snAccountLoginNeeds2SA = False
+    , snAccountLoginNeeds2FA = 0
+    , snSrpCompleteEmptyError = False
+    , snVerifyCodeLocks = False
+    , snVerifyDeviceCodeFails = False
+    , snSrpInitReturnsRetryCode = Nothing
+    }
+
+
+jsonHeaders :: [(HeaderName, ByteString)]
+jsonHeaders = [(hContentType, "application/json"), ("X-Apple-Session-Token", "mock-session-token-value")]
+
+
+lockedTrustData :: LBS.ByteString
+lockedTrustData =
+  "{\"securityCode\":{\"length\":6,\"tooManyCodesSent\":false,\"tooManyCodesValidated\":false\
+  \,\"securityCodeLocked\":true,\"securityCodeCooldown\":false}\
+  \,\"trustedPhoneNumbers\":[{\"id\":1,\"numberWithDialCode\":\"+1 test\",\"pushMode\":null}]\
+  \,\"noTrustedDevices\":false}"
+
+
+withMockApp :: Scenario -> (Int -> IO a) -> IO a
+withMockApp scenario action =
+  withMockAppCapturing scenario $ \port _ -> action port
+
+
+withMockAppCapturing :: Scenario -> (Int -> IORef [(ByteString, RequestHeaders)] -> IO a) -> IO a
+withMockAppCapturing scenario action = do
+  srpInit <- LBS.readFile =<< getDataFileName "testdata/srp_init_ok_test.json"
+  login2fa <- LBS.readFile =<< getDataFileName "testdata/login_2fa_test.json"
+  login2sa <- LBS.readFile =<< getDataFileName "testdata/login_2sa_test.json"
+  loginWorking <- LBS.readFile =<< getDataFileName "testdata/login_working_test.json"
+  listDevices <- LBS.readFile =<< getDataFileName "testdata/trusted_devices_test.json"
+  trustData <- LBS.readFile =<< getDataFileName "testdata/trust_data_test.json"
+  codeAttemptsRef <- newIORef (0 :: Int)
+  deviceCodeAttemptsRef <- newIORef (0 :: Int)
+  accountLoginRef <- newIORef (0 :: Int)
+  srpInitRetryRef <- newIORef (0 :: Int)
+  capturedRef <- newIORef []
+  testWithApplication
+    ( pure $
+        mockApp
+          scenario
+          capturedRef
+          srpInit
+          login2fa
+          login2sa
+          loginWorking
+          listDevices
+          trustData
+          codeAttemptsRef
+          deviceCodeAttemptsRef
+          accountLoginRef
+          srpInitRetryRef
+    )
+    (\port -> action port capturedRef)
+
+
+mockApp
+  :: Scenario
+  -> IORef [(ByteString, RequestHeaders)]
+  -> LBS.ByteString
+  -> LBS.ByteString
+  -> LBS.ByteString
+  -> LBS.ByteString
+  -> LBS.ByteString
+  -> LBS.ByteString
+  -> IORef Int
+  -> IORef Int
+  -> IORef Int
+  -> IORef Int
+  -> Application
+mockApp
+  scenario
+  capturedRef
+  srpInit
+  login2fa
+  login2sa
+  loginWorking
+  listDevices
+  trustData
+  codeAttemptsRef
+  deviceCodeAttemptsRef
+  accountLoginRef
+  srpInitRetryRef
+  req
+  respond = do
+    modifyIORef' capturedRef ((rawPathInfo req, requestHeaders req) :)
+    let method = requestMethod req
+        segs = pathInfo req
+        json st body = responseLBS st jsonHeaders body
+    resp <- case (method, segs) of
+      ("GET", ["appleauth", "auth"]) ->
+        pure $
+          json status200 $
+            if snVerifyCodeLocks scenario then lockedTrustData else trustData
+      ("POST", ["appleauth", "auth", "signin", "init"]) -> do
+        n <- readIORef srpInitRetryRef
+        writeIORef srpInitRetryRef (n + 1)
+        pure $ case snSrpInitReturnsRetryCode scenario of
+          Just code | n == 0 -> responseLBS (mkStatus code "") [] ""
+          _ -> json status200 srpInit
+      ("POST", ["appleauth", "auth", "signin", "complete"]) ->
+        pure $
+          if snSrpCompleteEmptyError scenario
+            then responseLBS status401 [] ""
+            else case snSrpOutcome scenario of
+              SrpOk -> json status200 "{}"
+              SrpNeeds2FA -> responseLBS status409 jsonHeaders "{}"
+      ("GET", ["appleauth", "auth", "2sv", "trust"]) ->
+        pure $ json status200 "{}"
+      ("PUT", ["appleauth", "auth", "verify", "trusteddevice", "securitycode"]) ->
+        pure $ responseLBS status204 [] ""
+      ("POST", ["appleauth", "auth", "verify", "trusteddevice", "securitycode"]) -> do
+        n <- readIORef deviceCodeAttemptsRef
+        writeIORef deviceCodeAttemptsRef (n + 1)
+        pure $
+          if snVerifyCodeLocks scenario
+            then responseLBS status400 [] ""
+            else
+              if snVerifyDeviceCodeFails scenario && n == 0
+                then responseLBS status400 [] ""
+                else json status200 "{}"
+      ("PUT", ["appleauth", "auth", "verify", "phone"]) ->
+        pure $ json status200 "{}"
+      ("POST", ["appleauth", "auth", "verify", "phone", "securitycode"]) ->
+        pure $ json status200 "true"
+      ("GET", ["setup", "ws", "1", "listDevices"]) ->
+        pure $ json status200 listDevices
+      ("POST", ["setup", "ws", "1", "sendVerificationCode"]) ->
+        pure $ json status200 "{}"
+      ("POST", ["setup", "ws", "1", "validateVerificationCode"]) -> do
+        n <- readIORef codeAttemptsRef
+        writeIORef codeAttemptsRef (n + 1)
+        pure $
+          if snValidateCodeFails scenario && n == 0
+            then responseLBS status400 [] ""
+            else json status200 "{}"
+      ("POST", ["setup", "ws", "1", "validate"]) ->
+        pure $
+          if snValidate scenario
+            then json status200 "{}"
+            else responseLBS status401 [] ""
+      ("POST", ["setup", "ws", "1", "accountLogin"]) -> do
+        n <- readIORef accountLoginRef
+        writeIORef accountLoginRef (n + 1)
+        pure $
+          if snAccountLoginNeeds2FA scenario > n
+            then json status200 login2fa
+            else
+              if snAccountLoginNeeds2SA scenario && n == 0
+                then json status200 login2sa
+                else json status200 loginWorking
+      _ -> pure $ responseLBS status404 [] "not found"
+    respond resp
diff --git a/test/HStratus/PBKDF2Spec.hs b/test/HStratus/PBKDF2Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/PBKDF2Spec.hs
@@ -0,0 +1,119 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.PBKDF2Spec
+Copyright   : (c) 2023 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+-}
+module HStratus.PBKDF2Spec (spec) where
+
+import qualified Crypto.Hash.SHA1 as SHA1
+import qualified Crypto.Hash.SHA256 as SHA256
+import qualified Crypto.Hash.SHA512 as SHA512
+import Data.Aeson
+  ( FromJSON (..)
+  , Object
+  , Value
+  , eitherDecodeFileStrict
+  , withArray
+  , withObject
+  , (.:)
+  )
+import Data.Aeson.Types (Parser)
+import Data.ByteString (ByteString)
+import Data.ByteString.Base16 (decode)
+import Data.Foldable (toList)
+import Data.Text.Encoding (encodeUtf8)
+import Data.Word (Word32, Word64, Word8)
+import Network.HStratus.Internal.PBKDF2 (PseudoRandomF, deriveKey, wrap)
+import Paths_hstratus_auth (getDataFileName)
+import Test.Hspec (Spec, context, describe, it, runIO, shouldBe)
+import Test.Hspec.Runner (SpecWith)
+
+
+spec :: Spec
+spec = describe "module Network.HStratus.PBKDF2" $ do
+  describe "deriveKey" $ do
+    context "when using the wycheProof test cases" $ do
+      specFrom "sha1" SHA1.hmac
+      specFrom "sha256" SHA256.hmac
+      specFrom "sha512" SHA512.hmac
+
+
+specFrom :: String -> PseudoRandomF -> Spec
+specFrom shaName pseudo = do
+  context ("with " ++ shaName ++ ".hmac as the pseudorandom function") $ do
+    testData <- runIO $ namedDataPath shaName >>= loadTestData
+    mapM_ (specWithFrom pseudo) testData
+
+
+specWithFrom :: PseudoRandomF -> TestDescription -> SpecWith ()
+specWithFrom pseudo td = do
+  let TestDescription{tdSalt, tdIterationCount = count, tdDerivedKey = key} = td
+  it ("test " ++ show (tdId td) ++ " should succeed") $ do
+    let pseudo' = wrap pseudo (tdDerivedLength td)
+        calc x = deriveKey x (tdPassword td) tdSalt count
+    calc <$> pseudo' `shouldBe` Right key
+
+
+loadTestData :: FilePath -> IO [TestDescription]
+loadTestData src =
+  let decodeDataFile aPath = fmap unTestFile <$> eitherDecodeFileStrict aPath
+   in decodeDataFile src >>= either fail pure
+
+
+namedDataPath :: String -> IO FilePath
+namedDataPath shaName =
+  let path = "testdata/pbkdf2_hmac" ++ shaName ++ "_test.json"
+   in getDataFileName path
+
+
+newtype TestFile = TestFile {unTestFile :: [TestDescription]}
+
+
+instance FromJSON TestFile where
+  parseJSON = fmap TestFile . parseTestFile
+
+
+parseTestFile :: Value -> Parser [TestDescription]
+parseTestFile =
+  let oneTest = withObject "test" parseTestDescription
+      manyTests = traverse oneTest . toList
+      testsInTestGroup o = o .: "tests" >>= withArray "[test]" manyTests
+      oneTestGroup = withObject "testsInTestGroup" testsInTestGroup
+      manyTestGroups = traverse oneTestGroup . toList
+      testGroupsAtTop o = o .: "testGroups" >>= withArray "[testGroups]" manyTestGroups
+   in withObject "dataFile" (fmap concat . testGroupsAtTop)
+
+
+data TestDescription = TestDescription
+  { tdId :: !Word8
+  , tdPassword :: !ByteString
+  , tdSalt :: !ByteString
+  , tdIterationCount :: !Word64
+  , tdDerivedLength :: !Word32
+  , tdDerivedKey :: !ByteString
+  }
+  deriving (Eq, Show)
+
+
+parseTestDescription :: Object -> Parser TestDescription
+parseTestDescription o = do
+  let parseBase16Bytes = either fail pure . decode . encodeUtf8
+  tdId <- o .: "tcId"
+  tdPassword <- o .: "password" >>= parseBase16Bytes
+  tdSalt <- o .: "salt" >>= parseBase16Bytes
+  tdIterationCount <- o .: "iterationCount"
+  tdDerivedLength <- o .: "dkLen"
+  tdDerivedKey <- o .: "dk" >>= parseBase16Bytes
+  pure
+    TestDescription
+      { tdId
+      , tdPassword
+      , tdSalt
+      , tdIterationCount
+      , tdDerivedKey
+      , tdDerivedLength
+      }
diff --git a/test/HStratus/SessionSpec.hs b/test/HStratus/SessionSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/SessionSpec.hs
@@ -0,0 +1,530 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.SessionSpec
+Copyright   : (c) 2023 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+-}
+module HStratus.SessionSpec (spec) where
+
+import Control.Monad (when)
+import Data.Aeson (decode, eitherDecodeFileStrict, encode, encodeFile, object, (.=))
+import Data.Aeson.Types (parseJSON, parseMaybe)
+import Data.Bits ((.&.))
+import Data.Either (isLeft)
+import Data.List (isInfixOf, sort)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (fromMaybe)
+import Data.String (IsString (..))
+import Data.Text (Text)
+import qualified Data.Text.IO as Text
+import Data.Word (Word16)
+import HStratus.TrustSpec (jsonKeysOf)
+import Network.HStratus.Internal.Session
+  ( SavedHeaders (..)
+  , accountDataPath
+  , accountDataRequires2FA
+  , accountDataRequires2SA
+  , appBase
+  , checkSecureMode
+  , clientIdPath
+  , cookiePath
+  , credentialsPath
+  , encodeFileAtomic
+  , loadAccountData
+  , loadSavedHeaders
+  , requireSecureFile
+  , saveAccountData
+  , saveCredentialsTo
+  , savedHeadersPath
+  , unknownAccountData
+  , updateSessionSavedHeaders
+  , (</>)
+  )
+import Network.HStratus.Session (AccountData (..), Credentials (..), Session (..), Webservice (..), loadSession)
+import System.Directory (createDirectory, doesFileExist)
+import System.Environment (setEnv)
+import System.IO.Error (ioeGetErrorString)
+import System.IO.Temp (withSystemTempDirectory)
+import System.Posix.Files (fileMode, getFileStatus, setFileMode)
+import Test.Hspec
+  ( Spec
+  , anyIOException
+  , around
+  , context
+  , describe
+  , it
+  , shouldBe
+  , shouldReturn
+  , shouldSatisfy
+  , shouldThrow
+  )
+import Test.QuickCheck
+  ( Arbitrary (arbitrary)
+  , Gen
+  , Property
+  , elements
+  , frequency
+  , listOf
+  )
+import Test.QuickCheck.Monadic (assert, monadicIO, pick, run)
+
+
+spec :: Spec
+spec = do
+  secureFileSpec
+  secureWriteSpec
+  checkSessionFilesSpec
+  sessionSpec
+  saveCredentialsSpec
+  accountDataSpec
+
+
+secureFileSpec :: Spec
+secureFileSpec = describe "module Network.HStratus.Internal.Session (file security)" $ do
+  describe "checkSecureMode" $ do
+    it "accepts mode 0o600" $
+      checkSecureMode 0o600 "/tmp/test" `shouldBe` Right ()
+    it "accepts mode 0o400 (read-only owner)" $
+      checkSecureMode 0o400 "/tmp/test" `shouldBe` Right ()
+    it "accepts mode 0o700 (owner-execute)" $
+      checkSecureMode 0o700 "/tmp/test" `shouldBe` Right ()
+    it "rejects mode 0o644 (world-readable)" $
+      checkSecureMode 0o644 "/tmp/test" `shouldSatisfy` isLeft
+    it "rejects mode 0o640 (group-readable)" $
+      checkSecureMode 0o640 "/tmp/test" `shouldSatisfy` isLeft
+    it "rejects mode 0o604 (world-readable, no group)" $
+      checkSecureMode 0o604 "/tmp/test" `shouldSatisfy` isLeft
+    it "includes the path in the error message" $
+      case checkSecureMode 0o644 "/some/path" of
+        Left msg -> "/some/path" `isInfixOf` msg `shouldBe` True
+        Right () -> fail "expected Left"
+    it "includes chmod hint in the error message" $
+      case checkSecureMode 0o644 "/some/path" of
+        Left msg -> "chmod 600" `isInfixOf` msg `shouldBe` True
+        Right () -> fail "expected Left"
+  describe "requireSecureFile" $ around withTmpDir $ do
+    it "does nothing when the file is absent" $ \tmpDir -> do
+      requireSecureFile (tmpDir </> "absent.txt")
+    it "does nothing when the file has mode 0o600" $ \tmpDir -> do
+      let path = tmpDir </> "secure.txt"
+      writeFile path "content"
+      setFileMode path 0o600
+      requireSecureFile path
+    it "throws when the file has mode 0o644" $ \tmpDir -> do
+      let path = tmpDir </> "insecure.txt"
+      writeFile path "content"
+      setFileMode path 0o644
+      requireSecureFile path `shouldThrow` anyIOException
+    it "includes the path in the error for a bad-permission file" $ \tmpDir -> do
+      let path = tmpDir </> "insecure.txt"
+      writeFile path "content"
+      setFileMode path 0o644
+      requireSecureFile path
+        `shouldThrow` (\e -> tmpDir `isInfixOf` ioeGetErrorString e)
+
+
+shouldHaveMode600 :: FilePath -> IO ()
+shouldHaveMode600 path = do
+  mode <- fileMode <$> getFileStatus path
+  (mode .&. 0o777) `shouldBe` 0o600
+
+
+secureWriteSpec :: Spec
+secureWriteSpec = describe "module Network.HStratus.Internal.Session (secure writes)" $ do
+  context "saveCredentialsTo" $ around withTmpDir $ do
+    it "writes credentials.json with mode 0o600" $ \tmpDir -> do
+      saveCredentialsTo tmpDir exampleCred
+      shouldHaveMode600 (credentialsPath tmpDir)
+  context "updateSessionSavedHeaders" $ around useTmp $ do
+    it "writes session headers with mode 0o600" $ \appRoot -> do
+      saveCredentialsTo appRoot exampleCred
+      s <- loadSession
+      updateSessionSavedHeaders s id
+      shouldHaveMode600 (savedHeadersPath appRoot (sessionCreds s))
+  context "saveAccountData" $ around useTmp $ do
+    it "writes account-data with mode 0o600" $ \appRoot -> do
+      saveCredentialsTo appRoot exampleCred
+      s <- loadSession
+      saveAccountData s unknownAccountData
+      shouldHaveMode600 (accountDataPath appRoot (sessionCreds s))
+  context "loadClientId (new file)" $ around useTmp $ do
+    it "creates client-id file with mode 0o600" $ \appRoot -> do
+      saveCredentialsTo appRoot exampleCred
+      s <- loadSession
+      shouldHaveMode600 (clientIdPath appRoot (sessionCreds s))
+
+
+checkSessionFilesSpec :: Spec
+checkSessionFilesSpec = describe "checkSessionFiles / loadSession" $ around useTmp $ do
+  it "succeeds when credentials.json has mode 0o600" $ \appRoot -> do
+    saveCredentialsTo appRoot exampleCred
+    _ <- loadSession
+    pure ()
+  it "fails when credentials.json has mode 0o644" $ \appRoot -> do
+    saveCredentialsTo appRoot exampleCred
+    setFileMode (credentialsPath appRoot) 0o644
+    loadSession `shouldThrow` anyIOException
+  it "fails when session headers file has mode 0o644" $ \appRoot -> do
+    saveCredentialsTo appRoot exampleCred
+    s <- loadSession
+    updateSessionSavedHeaders s id
+    setFileMode (savedHeadersPath appRoot (sessionCreds s)) 0o644
+    loadSession `shouldThrow` anyIOException
+
+
+-- save credential somewhere
+-- confirm Session loads
+
+-- save credentials
+-- save a pre-existing clientId
+-- loadSession; confirm loaded session as the clientId
+
+-- save credentials
+-- save generated SavedHeaders
+-- loadSession; confirm saveHeaders are loaded
+
+sessionSpec :: Spec
+sessionSpec = describe "module Network.HStratus.Session" $ do
+  context "Using an example Credential" $ do
+    let topDir = "/tmp/icloud_authspec"
+    context "cookiePath" $ do
+      it "should be computed correctly" $ do
+        let want = "/tmp/icloud_authspec/myaccountid-applecom.cookies.txt"
+        cookiePath topDir exampleCred `shouldBe` want
+
+    context "savedHeadersPath" $ do
+      it "should be computed correctly" $ do
+        let want = "/tmp/icloud_authspec/myaccountid-applecom.session.json"
+        savedHeadersPath topDir exampleCred `shouldBe` want
+
+    context "clientIdPath" $ do
+      it "should be computed correctly" $ do
+        let want = "/tmp/icloud_authspec/myaccountid-applecom.client-id.txt"
+        clientIdPath topDir exampleCred `shouldBe` want
+  savedHeadersFieldNamesSpec
+  loadSessionSpec
+  loadSavedHeadersSpec
+  updateSessionSavedHeadersSpec
+  encodeFileAtomicSpec
+
+
+savedHeadersFieldNamesSpec :: Spec
+savedHeadersFieldNamesSpec = describe "SavedHeaders JSON field names" $ do
+  it "uses the expected field names" $
+    jsonKeysOf (SavedHeaders (Just "a") (Just "b") (Just "c") (Just "d") (Just "e"))
+      `shouldBe` Just (sort ["country", "session_id", "session_token", "trust_token", "counter"])
+
+
+loadSessionSpec :: Spec
+loadSessionSpec = describe "loadSession" $ around useTmp $ do
+  context "with an invalid credentials file" $ do
+    it "should fail to load" $ \appRoot ->
+      failsOnBadCredentials appRoot `shouldThrow` anyIOException
+  context "with only the credentials file" $ do
+    it "should load a Session with a new clientId" prop_loadsSession
+  context "with a clientId file available" $ do
+    it "should load the saved clientId" prop_readsStoredCliendId
+  context "with a saved headers file" $ do
+    it "should load the saved headers" prop_readsStoredSavedHeaders
+
+
+loadSavedHeadersSpec :: Spec
+loadSavedHeadersSpec = describe "loadSavedHeaders" $ around useTmp $ do
+  context "with an invalid saved headers file" $ do
+    it "should fail to load" $ \appRoot ->
+      failsOnBadSavedHeaders appRoot `shouldThrow` anyIOException
+    it "includes a re-login hint in the error message" $ \appRoot ->
+      failsOnBadSavedHeaders appRoot
+        `shouldThrow` (\e -> "hstratus auth login" `isInfixOf` ioeGetErrorString e)
+
+
+updateSessionSavedHeadersSpec :: Spec
+updateSessionSavedHeadersSpec = describe "updateSessionSavedHeaders" $ around useTmp $ do
+  context "when some SaveHeaders are already saved" $ do
+    it "should update to the new headers" $ prop_updatesSavedHeaders True
+  context "when No SaveHeaders have been saved" $ do
+    it "should update to the new headers" $ prop_updatesSavedHeaders True
+
+
+useTmp :: (FilePath -> IO a) -> IO a
+useTmp = withSystemTempDirectory "icloud-auth" . asConfigHome
+
+
+setupInvalid :: FilePath -> IO ()
+setupInvalid path = Text.writeFile path "[}"
+
+
+failsOnBadCredentials :: FilePath -> IO Session
+failsOnBadCredentials appRoot = do
+  let path = credentialsPath appRoot
+  setupInvalid path
+  setFileMode path 0o600
+  loadSession
+
+
+failsOnBadSavedHeaders :: FilePath -> IO SavedHeaders
+failsOnBadSavedHeaders appRoot = do
+  saveCredentialsTo appRoot exampleCred
+  let shPath = savedHeadersPath appRoot exampleCred
+  setupInvalid shPath
+  setFileMode shPath 0o600
+  s <- loadSession
+  loadSavedHeaders s
+
+
+asConfigHome :: (FilePath -> IO a) -> FilePath -> IO a
+asConfigHome action root = do
+  setEnv "XDG_CONFIG_HOME" root
+  let appRoot = root </> appBase
+  createDirectory appRoot
+  action appRoot
+
+
+prop_loadsSession :: FilePath -> Property
+prop_loadsSession appRoot = monadicIO $ do
+  preCreds <- pick genPreCredentials
+  let creds = asCreds preCreds
+  s <- run $ do
+    saveCredentialsTo appRoot creds
+    loadSession
+  assert $ sessionClientId s /= "" && creds == sessionCreds s
+
+
+prop_readsStoredCliendId :: FilePath -> Property
+prop_readsStoredCliendId appRoot = monadicIO $ do
+  preCreds <- pick genPreCredentials
+  fakeId <- pick $ genIndexedSuffix "client-id-"
+  let creds = asCreds preCreds
+  session <- run $ do
+    saveCredentialsTo appRoot creds
+    let cidPath = clientIdPath appRoot creds
+    Text.writeFile cidPath fakeId
+    setFileMode cidPath 0o600
+    loadSession
+  assert $ fakeId == sessionClientId session
+
+
+prop_readsStoredSavedHeaders :: FilePath -> Property
+prop_readsStoredSavedHeaders appRoot = monadicIO $ do
+  preCreds <- pick genPreCredentials
+  savedHdrs <- pick genSaveHeaders
+  let creds = asCreds preCreds
+  savedHdrs' <- run $ do
+    saveCredentialsTo appRoot creds
+    s <- loadSession
+    updateSessionSavedHeaders s (const savedHdrs)
+    loadSavedHeaders s
+  assert $ savedHdrs == savedHdrs'
+
+
+prop_updatesSavedHeaders :: Bool -> FilePath -> Property
+prop_updatesSavedHeaders storeInitial appRoot = monadicIO $ do
+  preCreds <- pick genPreCredentials
+  savedHdrs <- pick genSaveHeaders
+  newHdrs <- pick genSaveHeaders
+  let creds = asCreds preCreds
+  loadedHdrs <- run $ do
+    saveCredentialsTo appRoot creds
+    when storeInitial $ do
+      let shPath = savedHeadersPath appRoot creds
+      encodeFile shPath savedHdrs
+      setFileMode shPath 0o600
+    s <- loadSession
+    updateSessionSavedHeaders s (const newHdrs)
+    loadSavedHeaders s
+  assert $ newHdrs == loadedHdrs
+
+
+saveCredentialsSpec :: Spec
+saveCredentialsSpec = describe "saveCredentialsTo" $ do
+  context "in a pre-existing directory" $ around withTmpDir $ do
+    it "creates the credentials file" $ \tmpDir -> do
+      saveCredentialsTo tmpDir exampleCred
+      doesFileExist (credentialsPath tmpDir) `shouldReturn` True
+    it "round-trips credentials through JSON" prop_saveLoadCredentials
+    it "overwrites when called a second time" prop_overwritesCredentials
+  context "when the target directory does not exist" $ do
+    it "creates the directory and the file" $
+      withSystemTempDirectory "icloud-auth-creds" $ \tmp -> do
+        let target = tmp </> "new-subdir"
+        saveCredentialsTo target exampleCred
+        doesFileExist (credentialsPath target) `shouldReturn` True
+
+
+withTmpDir :: (FilePath -> IO a) -> IO a
+withTmpDir = withSystemTempDirectory "icloud-auth-creds"
+
+
+prop_saveLoadCredentials :: FilePath -> Property
+prop_saveLoadCredentials tmpDir = monadicIO $ do
+  creds <- asCreds <$> pick genPreCredentials
+  result <- run $ do
+    saveCredentialsTo tmpDir creds
+    eitherDecodeFileStrict (credentialsPath tmpDir)
+  assert $ result == Right creds
+
+
+prop_overwritesCredentials :: FilePath -> Property
+prop_overwritesCredentials tmpDir = monadicIO $ do
+  creds1 <- asCreds <$> pick genPreCredentials
+  creds2 <- asCreds <$> pick genPreCredentials
+  result <- run $ do
+    saveCredentialsTo tmpDir creds1
+    saveCredentialsTo tmpDir creds2
+    eitherDecodeFileStrict (credentialsPath tmpDir)
+  assert $ result == Right creds2
+
+
+exampleCred :: Credentials
+exampleCred =
+  Credentials
+    { credAccountName = "my-account-id@apple.com"
+    , credPassword = "notasecret"
+    }
+
+
+type PreCredentials = (Text, Text)
+
+
+asCreds :: PreCredentials -> Credentials
+asCreds (credAccountName, credPassword) = Credentials{credAccountName, credPassword}
+
+
+genPreCredentials :: Gen PreCredentials
+genPreCredentials =
+  let mkId x = "account-" <> x <> "@apple.com"
+   in (,) <$> genIndexedTemplate mkId <*> genIndexedSuffix "password-"
+
+
+genSaveHeaders :: Gen SavedHeaders
+genSaveHeaders =
+  let arb pre = frequency [(2, pure Nothing), (1, Just <$> genIndexedSuffix pre)]
+   in SavedHeaders
+        <$> arb "country-"
+        <*> arb "session-id-"
+        <*> arb "session-token-"
+        <*> arb "trust-token-"
+        <*> arb "counter="
+
+
+genWord16 :: Gen Word16
+genWord16 = arbitrary
+
+
+genIndexedSuffix :: (Monoid a, IsString a) => a -> Gen a
+genIndexedSuffix pre = genIndexedTemplate (pre <>)
+
+
+genIndexedTemplate :: (IsString a) => (a -> a) -> Gen a
+genIndexedTemplate plate = plate . fromString . show <$> genWord16
+
+
+accountDataSpec :: Spec
+accountDataSpec = describe "module Network.HStratus.Session (AccountData)" $ do
+  context "AccountData" $ do
+    it "round-trips through JSON encoding" prop_jsonRoundtripAccountData
+  context "accountDataRequires2FA" $ do
+    it "is True when hsaVersion == 2 and challenged" $
+      accountDataRequires2FA (mkAccountData 2 True (Just False)) `shouldBe` True
+    it "is True when hsaVersion == 2, not challenged, but browser explicitly untrusted" $
+      accountDataRequires2FA (mkAccountData 2 False (Just False)) `shouldBe` True
+    it "is False when hsaVersion == 2, not challenged, and browser trusted" $
+      accountDataRequires2FA (mkAccountData 2 False (Just True)) `shouldBe` False
+    it "is False when hsaVersion == 2, not challenged, and hsaTrustedBrowser absent" $
+      accountDataRequires2FA (mkAccountData 2 False Nothing) `shouldBe` False
+    it "is False when hsaVersion is 1" $
+      accountDataRequires2FA (mkAccountData 1 True (Just False)) `shouldBe` False
+    it "is False when hsaVersion is 3 (unknown version)" $
+      accountDataRequires2FA (mkAccountData 3 True (Just False)) `shouldBe` False
+  context "accountDataRequires2SA" $ do
+    it "is True when hsaVersion is 1" $
+      accountDataRequires2SA (mkAccountData 1 False (Just False)) `shouldBe` True
+    it "is False when hsaVersion is 2" $
+      accountDataRequires2SA (mkAccountData 2 False (Just False)) `shouldBe` False
+    it "is False when hsaVersion is 0" $
+      accountDataRequires2SA (mkAccountData 0 False (Just False)) `shouldBe` False
+  context "AccountData JSON parsing" $ do
+    it "fails to parse from null JSON" $
+      (decode "null" :: Maybe AccountData) `shouldBe` Nothing
+    it "fails to parse when dsInfo is absent" $
+      (decode "{}" :: Maybe AccountData) `shouldBe` Nothing
+  context "saveAccountData / loadAccountData" $ around useTmp $ do
+    it "round-trips in a temp directory" prop_saveLoadAccountData
+
+
+prop_jsonRoundtripAccountData :: Property
+prop_jsonRoundtripAccountData = monadicIO $ do
+  ad <- pick genAccountData
+  assert $ decode (encode ad) == Just ad
+
+
+prop_saveLoadAccountData :: FilePath -> Property
+prop_saveLoadAccountData appRoot = monadicIO $ do
+  preCreds <- pick genPreCredentials
+  ad <- pick genAccountData
+  let creds = asCreds preCreds
+  loaded <- run $ do
+    saveCredentialsTo appRoot creds
+    s <- loadSession
+    saveAccountData s ad
+    loadAccountData s
+  assert $ Just ad == loaded
+
+
+mkAccountData :: Int -> Bool -> Maybe Bool -> AccountData
+mkAccountData ver challenged trusted =
+  AccountData
+    { adHsaVersion = ver
+    , adHsaChallengeRequired = challenged
+    , adHsaTrustedBrowser = trusted
+    , adWebservices = Map.empty
+    , adRaw = object []
+    }
+
+
+genAccountData :: Gen AccountData
+genAccountData = do
+  adHsaVersion <- abs <$> (arbitrary :: Gen Int)
+  adHsaChallengeRequired <- (arbitrary :: Gen Bool)
+  adHsaTrustedBrowser <- elements [Nothing, Just True, Just False]
+  adWebservices <- Map.fromList <$> listOf genWsPair
+  let trustedField = maybe [] (\t -> ["hsaTrustedBrowser" .= t]) adHsaTrustedBrowser
+      v =
+        object $
+          [ "dsInfo" .= object ["hsaVersion" .= adHsaVersion]
+          , "hsaChallengeRequired" .= adHsaChallengeRequired
+          , "webservices"
+              .= fmap
+                (\(Webservice url st) -> object $ ["url" .= url] <> maybe [] (\s -> ["status" .= s]) st)
+                adWebservices
+          ]
+            <> trustedField
+  pure $ fromMaybe unknownAccountData (parseMaybe parseJSON v)
+ where
+  genWsPair :: Gen (Text, Webservice)
+  genWsPair = (,) <$> elements wsNames <*> genWebservice
+  genWebservice :: Gen Webservice
+  genWebservice = Webservice <$> genIndexedSuffix "https://example.com/" <*> elements [Nothing, Just "active", Just "inactive"]
+  wsNames = ["findme", "contacts", "calendar", "mail"]
+
+
+encodeFileAtomicSpec :: Spec
+encodeFileAtomicSpec = describe "encodeFileAtomic" $ around useTmp $ do
+  it "round-trips a JSON value" $ \appRoot -> do
+    let path = appRoot </> "test.json"
+        value = object ["key" .= ("value" :: Text)]
+    encodeFileAtomic path value
+    result <- eitherDecodeFileStrict path
+    result `shouldBe` Right value
+  it "overwrites an existing file" $ \appRoot -> do
+    let path = appRoot </> "test.json"
+        old = object ["version" .= (1 :: Int)]
+        new = object ["version" .= (2 :: Int)]
+    encodeFileAtomic path old
+    encodeFileAtomic path new
+    result <- eitherDecodeFileStrict path
+    result `shouldBe` Right new
diff --git a/test/HStratus/TrustSpec.hs b/test/HStratus/TrustSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/HStratus/TrustSpec.hs
@@ -0,0 +1,263 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- |
+Module      : HStratus.TrustSpec
+Copyright   : (c) 2023 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD3
+-}
+module HStratus.TrustSpec (spec, encode, genTrustData, genTrustedList, jsonKeysOf) where
+
+import Data.Aeson (Key, ToJSON (..), Value (..), decode, eitherDecodeFileStrict, encode)
+import Data.Aeson.KeyMap (fromList)
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.List (sort)
+import Data.List.NonEmpty (NonEmpty (..))
+import qualified Data.List.NonEmpty as NE
+import Data.Maybe (catMaybes)
+import Data.String.Conv (toS)
+import Data.Text (Text)
+import qualified HStratus.Examples as Examples
+import Network.HStratus.Internal.Trust
+import Paths_hstratus_auth (getDataFileName)
+import System.IO.Silently (silence)
+import Test.Hspec
+  ( Spec
+  , context
+  , describe
+  , it
+  , shouldBe
+  )
+import Test.Hspec.Benri (endsJust, endsNothing, endsRight)
+import Test.Main (withStdin)
+import Test.QuickCheck
+  ( Arbitrary (arbitrary)
+  , Gen
+  , Property
+  , chooseInt
+  , elements
+  , forAll
+  , frequency
+  , listOf1
+  , vectorOf
+  )
+import Test.QuickCheck.Monadic (assert, monadicIO, pick, run)
+
+
+spec :: Spec
+spec = describe "module Network.HStratus.Trust" $ do
+  describe "TrustData" $ do
+    context "parsing generated examples to/from JSON" $ do
+      it "should succeed" prop_jsonRoundtripTrustData
+    context "parsing a hand-crafted Apple-shaped fixture" $ do
+      it "should succeed" $ do
+        fp <- getDataFileName "testdata/trust_data_test.json"
+        eitherDecodeFileStrict fp `endsRight` expectedTrustData
+  describe "CodeStatus JSON field names" $ do
+    it "uses the server field names" $
+      jsonKeysOf (CodeStatus 6 False False False False)
+        `shouldBe` Just (sort ["length", "tooManyCodesSent", "tooManyCodesValidated", "securityCodeLocked", "securityCodeCooldown"])
+  describe "CodeStatus JSON parsing" $ do
+    it "defaults all boolean fields to False when absent" $
+      decode "{\"length\":6}" `shouldBe` Just (CodeStatus 6 False False False False)
+  describe "TrustedPhone JSON field names" $ do
+    it "uses the server field names" $
+      jsonKeysOf (TrustedPhone 1 "+81 test" (Just "sms"))
+        `shouldBe` Just (sort ["id", "numberWithDialCode", "pushMode"])
+  describe "TrustedDevice JSON field names" $ do
+    it "uses the server field names" $
+      jsonKeysOf (TrustedDevice "id1" "iPhone" "iPhone14")
+        `shouldBe` Just (sort ["id", "name", "modelName"])
+  describe "TrustedDevice JSON parsing" $ do
+    it "defaults modelName to empty string when absent" $
+      decode "{\"id\":\"1\",\"name\":\"iPhone\"}" `shouldBe` Just (TrustedDevice "1" "iPhone" "")
+
+  describe "Setup2SADevice" $ do
+    context "parsing generated examples to/from JSON" $ do
+      it "should succeed" prop_jsonRoundtripSetup2SADevice
+
+  describe "setup2SADeviceLabel" $ do
+    it "returns phoneNumber when present" $
+      setup2SADeviceLabel (mkDevice [("phoneNumber", String "+1234")]) `shouldBe` "+1234"
+    it "returns name when phoneNumber is absent" $
+      setup2SADeviceLabel (mkDevice [("name", String "iPhone")]) `shouldBe` "iPhone"
+    it "prefers phoneNumber over name" $
+      setup2SADeviceLabel (mkDevice [("phoneNumber", String "+1234"), ("name", String "iPhone")]) `shouldBe` "+1234"
+    it "returns (unknown) when neither field is present" $
+      setup2SADeviceLabel (mkDevice [("deviceId", String "abc")]) `shouldBe` "(unknown)"
+
+  describe "selectPhone" $ do
+    context "when the selected input" $ do
+      context "is a number within the range" $ do
+        it "should succeed" (prop_selectsWithNonMaxIndex selectPhone genTrustedPhone)
+      context "is the maximum number" $ do
+        it "should succeed" (prop_selectsWithMaxIndex selectPhone genTrustedPhone)
+
+  describe "selectDevice" $ do
+    context "when the selected input" $ do
+      context "is a number within the range" $ do
+        it "should succeed" (prop_selectsWithNonMaxIndex selectDevice genTrustedDevice)
+      context "is the maximum number" $ do
+        it "should succeed" (prop_selectsWithMaxIndex selectDevice genTrustedDevice)
+
+  describe "selectSetupDevice" $ do
+    context "when the selected input" $ do
+      context "is a number within the range" $ do
+        it "should succeed" (prop_selectsWithNonMaxIndex selectSetupDevice genSetup2SADevice)
+      context "is the maximum number" $ do
+        it "should succeed" (prop_selectsWithMaxIndex selectSetupDevice genSetup2SADevice)
+
+  describe "selectTwoFaPhone" $ do
+    context "when noTrustedDevices is True" $ do
+      it "returns the first phone without prompting" $
+        silence (selectTwoFaPhone (mkTrustData True [twoFaPhone1, twoFaPhone2]))
+          `endsJust` twoFaPhone1
+      it "returns Nothing when the phone list is empty" $
+        endsNothing $
+          silence (selectTwoFaPhone (mkTrustData True []))
+    context "when noTrustedDevices is False" $ do
+      context "and the phone list is empty" $ do
+        it "returns Nothing without prompting" $
+          endsNothing $
+            silence (selectTwoFaPhone (mkTrustData False []))
+      context "and the user presses Enter" $ do
+        it "returns Nothing" $
+          withStdin "\n" $
+            endsNothing $
+              silence (selectTwoFaPhone (mkTrustData False [twoFaPhone1]))
+      context "and the user enters a valid index" $ do
+        it "returns the selected phone" $
+          withStdin "2" $
+            silence (selectTwoFaPhone (mkTrustData False [twoFaPhone1, twoFaPhone2]))
+              `endsJust` twoFaPhone2
+      context "and the user first enters an invalid index" $ do
+        it "retries and returns the selected phone" $
+          withStdin (toS ("99\n1" :: String)) $
+            silence (selectTwoFaPhone (mkTrustData False [twoFaPhone1, twoFaPhone2]))
+              `endsJust` twoFaPhone1
+
+
+prop_jsonRoundtripTrustData :: Property
+prop_jsonRoundtripTrustData = forAll genTrustData $ \td ->
+  decode (encode td) == Just td
+
+
+prop_jsonRoundtripSetup2SADevice :: Property
+prop_jsonRoundtripSetup2SADevice = forAll genSetup2SADevice $ \d ->
+  decode (encode d) == Just d
+
+
+mkDevice :: [(Key, Value)] -> Setup2SADevice
+mkDevice = Setup2SADevice . fromList
+
+
+genSetup2SADevice :: Gen Setup2SADevice
+genSetup2SADevice = do
+  phone <- genExWordMaybe
+  devId <- genExWord
+  let pairs =
+        catMaybes
+          [ Just ("deviceId", String devId)
+          , fmap (\p -> ("phoneNumber", String p)) phone
+          ]
+  pure $ Setup2SADevice $ fromList pairs
+
+
+genCodeStatus :: Gen CodeStatus
+genCodeStatus =
+  CodeStatus
+    <$> arbitrary
+    <*> arbitrary
+    <*> arbitrary
+    <*> arbitrary
+    <*> arbitrary
+
+
+genTrustedPhone :: Gen TrustedPhone
+genTrustedPhone = TrustedPhone <$> arbitrary <*> genExWord <*> genExWordMaybe
+
+
+genTrustedDevice :: Gen TrustedDevice
+genTrustedDevice = TrustedDevice <$> genExWord <*> genExWord <*> genExWord
+
+
+genTrustedList :: Gen TrustedList
+genTrustedList =
+  frequency
+    [ (1, TrustedPhoneNumbers . NE.fromList <$> listOf1 genTrustedPhone)
+    , (1, TrustedDevices . NE.fromList <$> listOf1 genTrustedDevice)
+    ]
+
+
+twoFaPhone1 :: TrustedPhone
+twoFaPhone1 = TrustedPhone 1 "+81 test-1" (Just "sms")
+
+
+twoFaPhone2 :: TrustedPhone
+twoFaPhone2 = TrustedPhone 2 "+1 test-2" Nothing
+
+
+mkTrustData :: Bool -> [TrustedPhone] -> TrustData
+mkTrustData noDevices phones =
+  TrustData
+    { tdList = case NE.nonEmpty phones of
+        Just nep -> TrustedPhoneNumbers nep
+        Nothing -> TrustedDevices (TrustedDevice "" "" "" :| [])
+    , tdSecurityCode = CodeStatus 6 False False False False
+    , tdNoTrustedDevices = noDevices
+    }
+
+
+useIOSelector :: (Eq a) => (NonEmpty a -> IO a) -> (Int, a, NonEmpty a) -> IO Bool
+useIOSelector selector (idx, want, xs) = do
+  withStdin (toS $ show idx) $ do
+    selected <- silence $ selector xs
+    pure $ selected == want
+
+
+prop_selectsWithNonMaxIndex :: (Eq a, Show a) => (NonEmpty a -> IO a) -> Gen a -> Property
+prop_selectsWithNonMaxIndex selector generator = monadicIO $ do
+  pick (genWithNonMaxIndex generator) >>= run . useIOSelector selector >>= assert
+
+
+prop_selectsWithMaxIndex :: (Eq a, Show a) => (NonEmpty a -> IO a) -> Gen a -> Property
+prop_selectsWithMaxIndex selector generator = monadicIO $ do
+  let useMax (_ignoredIndex, _ignoredSelection, xs) = (NE.length xs, NE.last xs, xs)
+      withNonMax = genWithNonMaxIndex generator
+  pick (fmap useMax withNonMax) >>= run . useIOSelector selector >>= assert
+
+
+genWithNonMaxIndex :: Gen a -> Gen (Int, a, NonEmpty a)
+genWithNonMaxIndex sourceGen = do
+  low <- chooseInt (1, 5)
+  high <- chooseInt (low, 10)
+  xs <- NE.fromList <$> vectorOf high sourceGen -- vectorOf high always gives exactly high elements, high >= 1
+  pure (low, NE.toList xs !! (low - 1), xs) -- low in [1..high], so in-bounds
+
+
+genTrustData :: Gen TrustData
+genTrustData = TrustData <$> genTrustedList <*> genCodeStatus <*> arbitrary
+
+
+genExWord :: Gen Text
+genExWord = elements Examples.wordz
+
+
+genExWordMaybe :: Gen (Maybe Text)
+genExWordMaybe = frequency [(1, pure Nothing), (1, Just <$> genExWord)]
+
+
+jsonKeysOf :: (ToJSON a) => a -> Maybe [Key]
+jsonKeysOf x = case toJSON x of
+  Object o -> Just (sort $ KeyMap.keys o)
+  _ -> Nothing
+
+
+expectedTrustData :: TrustData
+expectedTrustData =
+  TrustData
+    { tdList = TrustedPhoneNumbers (TrustedPhone 1 "+81 \x2022\x2022 \x2022\x2022\x2022\x2022 \x2022\&34" (Just "sms") :| [])
+    , tdSecurityCode = CodeStatus 6 False False False False
+    , tdNoTrustedDevices = False
+    }
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,48 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+
+{- |
+Module      : Main
+Copyright   : (c) 2026 Tim Emiola
+Maintainer  : Tim Emiola <adetokunbo@emio.la>
+SPDX-License-Identifier: BSD-3-Clause
+
+Test suite entry point for hstratus-auth.
+-}
+module Main where
+
+import qualified HStratus.ApiLoggerSpec as ApiLogger
+import qualified HStratus.Http.CliSpec as HttpCli
+import qualified HStratus.Http.EndpointsSpec as HttpEndpoints
+import qualified HStratus.Http.ErrorsSpec as HttpErrors
+import qualified HStratus.Http.HeadersSpec as HttpHeaders
+import qualified HStratus.HttpMockSpec as HttpMock
+import qualified HStratus.HttpSpec as Http
+import qualified HStratus.LoginFSMSpec as LoginFSM
+import qualified HStratus.PBKDF2Spec as PBKDF2
+import qualified HStratus.SessionSpec as Session
+import qualified HStratus.TrustSpec as Trust
+import System.IO
+  ( BufferMode (..)
+  , hSetBuffering
+  , stderr
+  , stdout
+  )
+import Test.Hspec
+
+
+main :: IO ()
+main = do
+  hSetBuffering stdout NoBuffering
+  hSetBuffering stderr NoBuffering
+  hspec $ do
+    Session.spec
+    Http.spec
+    ApiLogger.spec
+    HttpCli.spec
+    HttpEndpoints.spec
+    HttpErrors.spec
+    HttpHeaders.spec
+    HttpMock.spec
+    PBKDF2.spec
+    Trust.spec
+    LoginFSM.spec
diff --git a/testdata/auth_ok_test.json b/testdata/auth_ok_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/auth_ok_test.json
@@ -0,0 +1,3 @@
+{
+  "authType": "hsa2"
+}
diff --git a/testdata/login_2fa_test.json b/testdata/login_2fa_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/login_2fa_test.json
@@ -0,0 +1,244 @@
+{
+  "dsInfo": {
+    "lastName": "TARANTINO",
+    "iCDPEnabled": false,
+    "tantorMigrated": true,
+    "dsid": "quentintarantino",
+    "hsaEnabled": true,
+    "ironcadeMigrated": true,
+    "locale": "fr-fr_FR",
+    "brZoneConsolidated": false,
+    "isManagedAppleID": false,
+    "gilligan-invited": "true",
+    "appleIdAliases": [
+      "quentintarantino@me.com",
+      "quentintarantino@icloud.com"
+    ],
+    "hsaVersion": 2,
+    "isPaidDeveloper": false,
+    "countryCode": "FRA",
+    "notificationId": "12345678-1234-1234-1234-123456789012quentintarantino",
+    "primaryEmailVerified": true,
+    "aDsID": "123456-12-12345678-1234-1234-1234-123456789012quentintarantino",
+    "locked": false,
+    "hasICloudQualifyingDevice": true,
+    "primaryEmail": "quentintarantino@hotmail.fr",
+    "appleIdEntries": [
+      {
+        "isPrimary": true,
+        "type": "EMAIL",
+        "value": "quentintarantino@hotmail.fr"
+      },
+      {
+        "type": "EMAIL",
+        "value": "quentintarantino@me.com"
+      },
+      {
+        "type": "EMAIL",
+        "value": "quentintarantino@icloud.com"
+      }
+    ],
+    "gilligan-enabled": "true",
+    "fullName": "Quentin TARANTINO",
+    "languageCode": "fr-fr",
+    "appleId": "quentintarantino@hotmail.fr",
+    "firstName": "Quentin",
+    "iCloudAppleIdAlias": "quentintarantino@icloud.com",
+    "notesMigrated": true,
+    "hasPaymentInfo": true,
+    "pcsDeleted": false,
+    "appleIdAlias": "quentintarantino@me.com",
+    "brMigrated": true,
+    "statusCode": 2,
+    "familyEligible": true
+  },
+  "hasMinimumDeviceForPhotosWeb": true,
+  "iCDPEnabled": false,
+  "webservices": {
+    "reminders": {
+      "url": "https://p31-remindersws.icloud.com:443",
+      "status": "active"
+    },
+    "notes": {
+      "url": "https://p38-notesws.icloud.com:443",
+      "status": "active"
+    },
+    "mail": {
+      "url": "https://p38-mailws.icloud.com:443",
+      "status": "active"
+    },
+    "ckdatabasews": {
+      "pcsRequired": true,
+      "url": "https://p31-ckdatabasews.icloud.com:443",
+      "status": "active"
+    },
+    "photosupload": {
+      "pcsRequired": true,
+      "url": "https://p31-uploadphotosws.icloud.com:443",
+      "status": "active"
+    },
+    "photos": {
+      "pcsRequired": true,
+      "uploadUrl": "https://p31-uploadphotosws.icloud.com:443",
+      "url": "https://p31-photosws.icloud.com:443",
+      "status": "active"
+    },
+    "drivews": {
+      "pcsRequired": true,
+      "url": "https://p31-drivews.icloud.com:443",
+      "status": "active"
+    },
+    "uploadimagews": {
+      "url": "https://p31-uploadimagews.icloud.com:443",
+      "status": "active"
+    },
+    "schoolwork": {},
+    "cksharews": {
+      "url": "https://p31-ckshare.icloud.com:443",
+      "status": "active"
+    },
+    "findme": {
+      "url": "https://p31-fmipweb.icloud.com:443",
+      "status": "active"
+    },
+    "ckdeviceservice": {
+      "url": "https://p31-ckdevice.icloud.com:443"
+    },
+    "iworkthumbnailws": {
+      "url": "https://p31-iworkthumbnailws.icloud.com:443",
+      "status": "active"
+    },
+    "calendar": {
+      "url": "https://p31-calendarws.icloud.com:443",
+      "status": "active"
+    },
+    "docws": {
+      "pcsRequired": true,
+      "url": "https://p31-docws.icloud.com:443",
+      "status": "active"
+    },
+    "settings": {
+      "url": "https://p31-settingsws.icloud.com:443",
+      "status": "active"
+    },
+    "streams": {
+      "url": "https://p31-streams.icloud.com:443",
+      "status": "active"
+    },
+    "keyvalue": {
+      "url": "https://p31-keyvalueservice.icloud.com:443",
+      "status": "active"
+    },
+    "archivews": {
+      "url": "https://p31-archivews.icloud.com:443",
+      "status": "active"
+    },
+    "push": {
+      "url": "https://p31-pushws.icloud.com:443",
+      "status": "active"
+    },
+    "iwmb": {
+      "url": "https://p31-iwmb.icloud.com:443",
+      "status": "active"
+    },
+    "iworkexportws": {
+      "url": "https://p31-iworkexportws.icloud.com:443",
+      "status": "active"
+    },
+    "geows": {
+      "url": "https://p31-geows.icloud.com:443",
+      "status": "active"
+    },
+    "account": {
+      "iCloudEnv": {
+        "shortId": "p",
+        "vipSuffix": "prod"
+      },
+      "url": "https://p31-setup.icloud.com:443",
+      "status": "active"
+    },
+    "fmf": {
+      "url": "https://p31-fmfweb.icloud.com:443",
+      "status": "active"
+    },
+    "contacts": {
+      "url": "https://p31-contactsws.icloud.com:443",
+      "status": "active"
+    }
+  },
+  "pcsEnabled": true,
+  "configBag": {
+    "urls": {
+      "accountCreateUI": "https://appleid.apple.com/widget/account/?widgetKey=widget_keyquentintarantino#!create",
+      "accountLoginUI": "https://idmsa.apple.com/appleauth/auth/signin?widgetKey=widget_keyquentintarantino",
+      "accountLogin": "https://setup.icloud.com/setup/ws/1/accountLogin",
+      "accountRepairUI": "https://appleid.apple.com/widget/account/?widgetKey=widget_keyquentintarantino#!repair",
+      "downloadICloudTerms": "https://setup.icloud.com/setup/ws/1/downloadLiteTerms",
+      "repairDone": "https://setup.icloud.com/setup/ws/1/repairDone",
+      "accountAuthorizeUI": "https://idmsa.apple.com/appleauth/auth/authorize/signin?client_id=widget_keyquentintarantino",
+      "vettingUrlForEmail": "https://id.apple.com/IDMSEmailVetting/vetShareEmail",
+      "accountCreate": "https://setup.icloud.com/setup/ws/1/createLiteAccount",
+      "getICloudTerms": "https://setup.icloud.com/setup/ws/1/getTerms",
+      "vettingUrlForPhone": "https://id.apple.com/IDMSEmailVetting/vetSharePhone"
+    },
+    "accountCreateEnabled": "true"
+  },
+  "hsaTrustedBrowser": false,
+  "appsOrder": [
+    "mail",
+    "contacts",
+    "calendar",
+    "photos",
+    "iclouddrive",
+    "notes3",
+    "reminders",
+    "pages",
+    "numbers",
+    "keynote",
+    "newspublisher",
+    "fmf",
+    "find",
+    "settings"
+  ],
+  "version": 2,
+  "isExtendedLogin": true,
+  "pcsServiceIdentitiesIncluded": false,
+  "hsaChallengeRequired": true,
+  "requestInfo": {
+    "country": "FR",
+    "timeZone": "GMT+1",
+    "region": "IDF"
+  },
+  "pcsDeleted": false,
+  "iCloudInfo": {
+    "SafariBookmarksHasMigratedToCloudKit": true
+  },
+  "apps": {
+    "calendar": {},
+    "reminders": {},
+    "keynote": {
+      "isQualifiedForBeta": true
+    },
+    "settings": {
+      "canLaunchWithOneFactor": true
+    },
+    "mail": {},
+    "numbers": {
+      "isQualifiedForBeta": true
+    },
+    "photos": {},
+    "pages": {
+      "isQualifiedForBeta": true
+    },
+    "notes3": {},
+    "find": {
+      "canLaunchWithOneFactor": true
+    },
+    "iclouddrive": {},
+    "newspublisher": {
+      "isHidden": true
+    },
+    "fmf": {},
+    "contacts": {}
+  }
+}
diff --git a/testdata/login_2sa_test.json b/testdata/login_2sa_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/login_2sa_test.json
@@ -0,0 +1,6 @@
+{
+  "dsInfo": {"hsaVersion": 1},
+  "hsaChallengeRequired": false,
+  "hsaTrustedBrowser": false,
+  "webservices": {}
+}
diff --git a/testdata/login_working_test.json b/testdata/login_working_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/login_working_test.json
@@ -0,0 +1,244 @@
+{
+  "dsInfo": {
+    "lastName": "TARANTINO",
+    "iCDPEnabled": false,
+    "tantorMigrated": true,
+    "dsid": "quentintarantino",
+    "hsaEnabled": true,
+    "ironcadeMigrated": true,
+    "locale": "fr-fr_FR",
+    "brZoneConsolidated": false,
+    "isManagedAppleID": false,
+    "gilligan-invited": "true",
+    "appleIdAliases": [
+      "quentintarantino@me.com",
+      "quentintarantino@icloud.com"
+    ],
+    "hsaVersion": 2,
+    "isPaidDeveloper": false,
+    "countryCode": "FRA",
+    "notificationId": "12345678-1234-1234-1234-123456789012quentintarantino",
+    "primaryEmailVerified": true,
+    "aDsID": "123456-12-12345678-1234-1234-1234-123456789012quentintarantino",
+    "locked": false,
+    "hasICloudQualifyingDevice": true,
+    "primaryEmail": "quentintarantino@hotmail.fr",
+    "appleIdEntries": [
+      {
+        "isPrimary": true,
+        "type": "EMAIL",
+        "value": "quentintarantino@hotmail.fr"
+      },
+      {
+        "type": "EMAIL",
+        "value": "quentintarantino@me.com"
+      },
+      {
+        "type": "EMAIL",
+        "value": "quentintarantino@icloud.com"
+      }
+    ],
+    "gilligan-enabled": "true",
+    "fullName": "Quentin TARANTINO",
+    "languageCode": "fr-fr",
+    "appleId": "quentintarantino@hotmail.fr",
+    "firstName": "Quentin",
+    "iCloudAppleIdAlias": "quentintarantino@icloud.com",
+    "notesMigrated": true,
+    "hasPaymentInfo": false,
+    "pcsDeleted": false,
+    "appleIdAlias": "quentintarantino@me.com",
+    "brMigrated": true,
+    "statusCode": 2,
+    "familyEligible": true
+  },
+  "hasMinimumDeviceForPhotosWeb": true,
+  "iCDPEnabled": false,
+  "webservices": {
+    "reminders": {
+      "url": "https://p31-remindersws.icloud.com:443",
+      "status": "active"
+    },
+    "notes": {
+      "url": "https://p38-notesws.icloud.com:443",
+      "status": "active"
+    },
+    "mail": {
+      "url": "https://p38-mailws.icloud.com:443",
+      "status": "active"
+    },
+    "ckdatabasews": {
+      "pcsRequired": true,
+      "url": "https://p31-ckdatabasews.icloud.com:443",
+      "status": "active"
+    },
+    "photosupload": {
+      "pcsRequired": true,
+      "url": "https://p31-uploadphotosws.icloud.com:443",
+      "status": "active"
+    },
+    "photos": {
+      "pcsRequired": true,
+      "uploadUrl": "https://p31-uploadphotosws.icloud.com:443",
+      "url": "https://p31-photosws.icloud.com:443",
+      "status": "active"
+    },
+    "drivews": {
+      "pcsRequired": true,
+      "url": "https://p31-drivews.icloud.com:443",
+      "status": "active"
+    },
+    "uploadimagews": {
+      "url": "https://p31-uploadimagews.icloud.com:443",
+      "status": "active"
+    },
+    "schoolwork": {},
+    "cksharews": {
+      "url": "https://p31-ckshare.icloud.com:443",
+      "status": "active"
+    },
+    "findme": {
+      "url": "https://p31-fmipweb.icloud.com:443",
+      "status": "active"
+    },
+    "ckdeviceservice": {
+      "url": "https://p31-ckdevice.icloud.com:443"
+    },
+    "iworkthumbnailws": {
+      "url": "https://p31-iworkthumbnailws.icloud.com:443",
+      "status": "active"
+    },
+    "calendar": {
+      "url": "https://p31-calendarws.icloud.com:443",
+      "status": "active"
+    },
+    "docws": {
+      "pcsRequired": true,
+      "url": "https://p31-docws.icloud.com:443",
+      "status": "active"
+    },
+    "settings": {
+      "url": "https://p31-settingsws.icloud.com:443",
+      "status": "active"
+    },
+    "streams": {
+      "url": "https://p31-streams.icloud.com:443",
+      "status": "active"
+    },
+    "keyvalue": {
+      "url": "https://p31-keyvalueservice.icloud.com:443",
+      "status": "active"
+    },
+    "archivews": {
+      "url": "https://p31-archivews.icloud.com:443",
+      "status": "active"
+    },
+    "push": {
+      "url": "https://p31-pushws.icloud.com:443",
+      "status": "active"
+    },
+    "iwmb": {
+      "url": "https://p31-iwmb.icloud.com:443",
+      "status": "active"
+    },
+    "iworkexportws": {
+      "url": "https://p31-iworkexportws.icloud.com:443",
+      "status": "active"
+    },
+    "geows": {
+      "url": "https://p31-geows.icloud.com:443",
+      "status": "active"
+    },
+    "account": {
+      "iCloudEnv": {
+        "shortId": "p",
+        "vipSuffix": "prod"
+      },
+      "url": "https://p31-setup.icloud.com:443",
+      "status": "active"
+    },
+    "fmf": {
+      "url": "https://p31-fmfweb.icloud.com:443",
+      "status": "active"
+    },
+    "contacts": {
+      "url": "https://p31-contactsws.icloud.com:443",
+      "status": "active"
+    }
+  },
+  "pcsEnabled": true,
+  "configBag": {
+    "urls": {
+      "accountCreateUI": "https://appleid.apple.com/widget/account/?widgetKey=widget_keyquentintarantino#!create",
+      "accountLoginUI": "https://idmsa.apple.com/appleauth/auth/signin?widgetKey=widget_keyquentintarantino",
+      "accountLogin": "https://setup.icloud.com/setup/ws/1/accountLogin",
+      "accountRepairUI": "https://appleid.apple.com/widget/account/?widgetKey=widget_keyquentintarantino#!repair",
+      "downloadICloudTerms": "https://setup.icloud.com/setup/ws/1/downloadLiteTerms",
+      "repairDone": "https://setup.icloud.com/setup/ws/1/repairDone",
+      "accountAuthorizeUI": "https://idmsa.apple.com/appleauth/auth/authorize/signin?client_id=widget_keyquentintarantino",
+      "vettingUrlForEmail": "https://id.apple.com/IDMSEmailVetting/vetShareEmail",
+      "accountCreate": "https://setup.icloud.com/setup/ws/1/createLiteAccount",
+      "getICloudTerms": "https://setup.icloud.com/setup/ws/1/getTerms",
+      "vettingUrlForPhone": "https://id.apple.com/IDMSEmailVetting/vetSharePhone"
+    },
+    "accountCreateEnabled": "true"
+  },
+  "hsaTrustedBrowser": true,
+  "appsOrder": [
+    "mail",
+    "contacts",
+    "calendar",
+    "photos",
+    "iclouddrive",
+    "notes3",
+    "reminders",
+    "pages",
+    "numbers",
+    "keynote",
+    "newspublisher",
+    "fmf",
+    "find",
+    "settings"
+  ],
+  "version": 2,
+  "isExtendedLogin": true,
+  "pcsServiceIdentitiesIncluded": true,
+  "hsaChallengeRequired": false,
+  "requestInfo": {
+    "country": "FR",
+    "timeZone": "GMT+1",
+    "region": "IDF"
+  },
+  "pcsDeleted": false,
+  "iCloudInfo": {
+    "SafariBookmarksHasMigratedToCloudKit": true
+  },
+  "apps": {
+    "calendar": {},
+    "reminders": {},
+    "keynote": {
+      "isQualifiedForBeta": true
+    },
+    "settings": {
+      "canLaunchWithOneFactor": true
+    },
+    "mail": {},
+    "numbers": {
+      "isQualifiedForBeta": true
+    },
+    "photos": {},
+    "pages": {
+      "isQualifiedForBeta": true
+    },
+    "notes3": {},
+    "find": {
+      "canLaunchWithOneFactor": true
+    },
+    "iclouddrive": {},
+    "newspublisher": {
+      "isHidden": true
+    },
+    "fmf": {},
+    "contacts": {}
+  }
+}
diff --git a/testdata/pbkdf2_hmacsha1_test.json b/testdata/pbkdf2_hmacsha1_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/pbkdf2_hmacsha1_test.json
@@ -0,0 +1,937 @@
+{
+  "algorithm" : "PBKDF2-HMACSHA1",
+  "schema" : "pbkdf_test_schema.json",
+  "generatorVersion" : "0.9",
+  "numberOfTests" : 64,
+  "header" : [
+    "Test vector of type PbkdfTest are for password based key derivations."
+  ],
+  "notes" : {
+    "Ascii" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of ASCII characters."
+    },
+    "LargeIterationCount" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a high iteration count"
+    },
+    "NonUtf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is not a valid UTF-8 string."
+    },
+    "Printable" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of printable ASCII characters."
+    },
+    "Pseudorandom" : {
+      "bugType" : "BASIC",
+      "description" : "Pseudorandomly generated test vector"
+    },
+    "Rfc6070" : {
+      "bugType" : "BASIC",
+      "description" : "Known test vector from RFC 6070"
+    },
+    "Utf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is a valid UTF-8 string."
+    }
+  },
+  "testGroups" : [
+    {
+      "type" : "PbkdfTest",
+      "tests" : [
+        {
+          "tcId" : 1,
+          "comment" : "RFC 6070",
+          "flags" : [
+            "Rfc6070",
+            "Printable"
+          ],
+          "password" : "70617373776f7264",
+          "salt" : "73616c74",
+          "iterationCount" : 1,
+          "dkLen" : 20,
+          "dk" : "0c60c80f961f0e71f3a9b524af6012062fe037a6",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 2,
+          "comment" : "RFC 6070",
+          "flags" : [
+            "Rfc6070",
+            "Printable"
+          ],
+          "password" : "70617373776f7264",
+          "salt" : "73616c74",
+          "iterationCount" : 2,
+          "dkLen" : 20,
+          "dk" : "ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 3,
+          "comment" : "RFC 6070",
+          "flags" : [
+            "Rfc6070",
+            "Printable"
+          ],
+          "password" : "70617373776f7264",
+          "salt" : "73616c74",
+          "iterationCount" : 4096,
+          "dkLen" : 20,
+          "dk" : "4b007901b765489abead49d926f721d065a429c1",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 4,
+          "comment" : "RFC 6070",
+          "flags" : [
+            "Rfc6070",
+            "LargeIterationCount",
+            "Printable"
+          ],
+          "password" : "70617373776f7264",
+          "salt" : "73616c74",
+          "iterationCount" : 16777216,
+          "dkLen" : 20,
+          "dk" : "eefe3d61cd4da4e4e9945b3d6ba2158c2634e984",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 5,
+          "comment" : "RFC 6070",
+          "flags" : [
+            "Rfc6070",
+            "Printable"
+          ],
+          "password" : "70617373776f726450415353574f524470617373776f7264",
+          "salt" : "73616c7453414c5473616c7453414c5473616c7453414c5473616c7453414c5473616c74",
+          "iterationCount" : 4096,
+          "dkLen" : 25,
+          "dk" : "3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 6,
+          "comment" : "RFC 6070",
+          "flags" : [
+            "Rfc6070",
+            "Ascii"
+          ],
+          "password" : "7061737300776f7264",
+          "salt" : "7361006c74",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "56fa6aa75548099dcc37d7f03425e0c3",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 7,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7130577430643470",
+          "salt" : "798acc7c76739d75",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "9ea245e919e491118087fc369142e2ee",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 8,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "5a30673349567272",
+          "salt" : "84bbd18de5ec10ff",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "b874ff052f517d94eb6cb7edd2ede57a7d96c01eea5dbe4fafc47d6fce32ce28defb14f7980c5ed3ba10",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 9,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7439315572766f47",
+          "salt" : "5d76db9ca0f0bae2",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "56799b703ed17b58b9e3ee12c80f93b35b83baa769789c539f7a4cb3e825db5df8c7afed85bb276a01aae0dc78beb492de832b581c7d5b899b184516cf028e90f7",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 10,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6b6f67583748447a",
+          "salt" : "0a8fbd0232a3a7f6e60d6564c92ea35f",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "8e7c43f3a3baa0ac7e8e24db0b99ea45",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 11,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "4939745242645168",
+          "salt" : "0a3bb93c0de86a174ab005b8089706ab",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "ab713dcf744ded086b1b588f570ba0c1876e8737c488a852216add421c2dd70ff3b58e5390c80bbe9103",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 12,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f4f4d37324f6330",
+          "salt" : "3827c3b6b1e6de7aab096eb75e23610d",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "8cd3a480add2dfd8fbfee4f49c55b6b2980955635474c5a795d1d6cdca9e0934797cd5ea797a1cf8cfa7d975a63b3f66855ec6b4a97dde26e49bd149c6259f6f52",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 13,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6761734354484546654d36526f61386349",
+          "salt" : "cac9ae7ef670990e",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "5e71edeb8746e5215b0949e0f64a03aa",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 14,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75375330684678774e33586f76707a6551",
+          "salt" : "201b1e277f4ff955",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "349d048452b7fc74ae8e7b373925621321ff807968e3ff4d5a31b815ace5b95d07cd868f20f958161c68",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 15,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "51524b6a6d6d6b48524462507178696868",
+          "salt" : "e71d6a702d2d54bc",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "5c480790477441c254bc89611d52e5cb83fb0e6eb9fe1000344c638dfd134d67d4c633f6fdbb3316dc57115f34b0e9d4186b96023d6d0c6b90da34720731bdad6b",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 16,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "3732317a52704b79306b4c6d3862317279",
+          "salt" : "8b9314972ef99ac98b69b6c2ac4247c7",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "9edef2939c154b91d359876d41772d79",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 17,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75507078436d7536577a68535030553947",
+          "salt" : "874d7cc9369ff9988642ffe05a7f2153",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "69cc4a0b7374717be2312a49cc4768a8e40a360f8637eb475dc125f253a54747edfcb64132ce8097a9ed",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 18,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f39505350597a445349783365374d7063",
+          "salt" : "1569909588ec25a3afa147cb7fff9fb1",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "744a5c9831b81db08874abdc9cd0afd7bc7f11250da19ad6aa61ed02626b1d52dcbf9bbb34dd44c65a8a7973f34244e0e16f27e31ee45c37d84ad1f9465445ddc9",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 19,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "4b2d6c600e66535c7f3d6d6c",
+          "salt" : "7a4c02db8bd8fcc2",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "8739361a2fa899f6d5cceba27d4142da",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 20,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "287e74200e6b69285f5f7e00",
+          "salt" : "dddf018487fa4b95",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "68a1ad4082c5f45e339ac85e44fbe4368fd604d73030bab4d459e0f7e79a709293a349305bc91ac86de4",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 21,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0f3b5e220e1c000c2a703a4d",
+          "salt" : "7cf71b19c0daf499",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "b58ad2cb772a9eb1bae46afcfc7738a043bcc3ff602071850d3019b8935270dc20a2ee90c5cb8f6c323b49f7477797352b1ba0b7973f6a2652cd2bb319f092f371",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 22,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "1f4b7b354903545f2b07720b",
+          "salt" : "62f91ea01f3a18aa2f122bd15c4a615b",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "deea05da06df69c5401517d132678f90",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 23,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5d7e1f70174b2f616c0d6722",
+          "salt" : "4fb602173327228431dd8d088154c7ec",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "05489b534681b9eb74d4bac4caeaa9e7fa5977dca35314b0118f57662b539b016b38c6393c8c75aa419d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 24,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2657324e694c6b242b174f1a",
+          "salt" : "3cad67ef1eaf3aaafb5ee372d6b1ecb4",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "a175e5aa360bd47b5e0e7d4d7bb29d0e4d5eef89f80e30e5570cb2b21aabdffb37996aee3dfefef1d79d1ec9fa0198276db9ed763770f1127d4d3e0c047037ab0a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 25,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "15296b0933473119180f3c0645202d5221633803",
+          "salt" : "d7412e4137fc4410",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "6033619ba0f6a1c4d2877ab466cc8bca",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 26,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2a1124361d53464245580130076d6b54001e3d01",
+          "salt" : "7ecaaed1eab03c3f",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "2e481954bbb8ac4e330a1d28ea8f736d63a1f760bb58d97a70855ffe8ff9480972c46b645095470a8cda",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 27,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "666252024e18233308430820127e753e6b041f56",
+          "salt" : "756fafa7fac1f019",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "95273cd50ba4a4e419b2e3a59d4587291188710912bcab5bf7640deb5f34c9c046f410f4e6b83192a6d36e96b5b8f11a898fb69d1ea6df38614138165c5438ee86",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 28,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "7a106c2773542c791d2f0a02632b69644f666278",
+          "salt" : "a36350db68186d747254bd9835219487",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "d206f75207b50786c7571d9a4b21a2a0",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 29,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0b3c6f070149615b5b461c7f5c3a366a6d375402",
+          "salt" : "42be6c8fcd7858458fb97cece0069524",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "6a0ffe5218cde4ec89dad3bc6ff32133c31b86baf45740fdd1313b8b2c525871fce7be5b312999bde78d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 30,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5177161d237c1b64426353434113015665344a6c",
+          "salt" : "596dbf5830aa8b00682429883e0b1ed0",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "52ec2859141885b77108eade00564a7564c41aa1747af8ef2fee384ada8e82c51743a5005d3ca4f855339837e8ff2e8fe78dcb17904a192591f508c801fc4c8abc",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 31,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0a3d1bed38acc83",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "672b655566b8ce79b5e5244b2bb59472",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 32,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0bcccab64cf9a",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "b753a37266ffa863239bf199abf0a8eef11c7a40cfc058ea4d46f58353d08a7bffda005327f98db8fd1d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 33,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c4bddd8f4ad69d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "28bffd8c6c6a6231336c3948c5a00510f433be50c1a36ebc2f180ace80feb4914fa35204386c2b6eb41e634951702f6de924bcffaaf4d976c01794c64c6af3d08e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 34,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c299d292cd92dcb8",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "5be67e596d953ede56531b9a66bbe566",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 35,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d086d0bed193d9b6",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "f73504788a1c491bc856cb3ab9b873f07cf1dba837c100997029a206f19169583c8a2c4efe1e2bbca6aa",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 36,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da8ddcacde86d3b0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "8ad0fc0024268cc557baad39644cbbe03fbc251262a51fb20f5dd2f1b4e14e73f4f53f6912e3a75f69f6ec6a42273411069df812899e4877323351b6056d8f21a2",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 37,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "cfbec3b9d7acc7a9d585d1a5c28fcca119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "4a5fdfd0cafdbb8793806299275372dd",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 38,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d29cd099d197d1b0da9aca8ec2a3daa5c38a",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "0479f3777ab64d13f2b514526b149686347688d824be30e077b2bcd4c05cbca845e3091b00d3b049e721",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 39,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de89d193d18c75c2b8c6bcd190d198c3a2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "7374cbb82049ccfb9271ee9df3dcdcf5f64870f356816e5077f8a5fb0cad52e14df3609814b771bf7d151de23d91584b973bc7fd34ee955a893f904fcfdc9e0215",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 40,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da9dd096cb93db88dbbed495ceaacf853c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "f90c5c5cab441e656c21a259fb83547e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 41,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de81d0a6cea9c7b3cbabce9bda90d29f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "bf873cb5210319562901a01c9fc8521a41b4d0397f774853f2499ec6b6b37490c4a8487523868af2ee3c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 42,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d281da97c8a3d8b7ceb9db80dc8dd4a0c3be",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "dcd906b27d8cffe87013fbb1557877e21c2bb58fcdec5f94d9a18beb523902c60fc4d63bb78f58cff300309f4d3411e8f524dadeadd956d94c551e3370b55c60b1",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 43,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "8423ec7ea4ca4b03",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "5409681bde39075bda761ad33cfd06f8",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 44,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "2c3c932bf0648bda",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "357f1e696f62b232b242d6a64b680fb73ccda4cdde09cc7ade62d5a9f828f4ac2f728a687c79a079c4ff",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 45,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "393d474fd84a259d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "e04e777c27b3ab84cd7647751f710fa37f50019f2b1536ae52b3f7893dd9bcd6345fa2e1459501c01dfe65da07dd83ff6e88e211418c88fec36410e6efaaf59240",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 46,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "48997492c3528738",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "1436967f0de829ae653a31c45168525c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 47,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "74064c3ebc53d676",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "6b7433a14d6225b5222fb7d5f5dfe674154c075ad83d13dbb274ccf6880f75c7545d35bc6e7bfcfa814b",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 48,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "668d4f2c8f86f4f0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "23c9045a0a037f60d0ef0e4058c8a3633714c67e4d3f4637499de4d9c949248bfce2bc7cd6bac4cfe74e9c28a221401d3ea7d99e0b33ca23bcd66ff239d1e8c40d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 49,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "e3feb0f96dec61e9dd451465f88f132119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "16ef25c3c98e34147b33a8d33813b13f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 50,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "5c9ca419dc576470269a828e40a3aea5ca",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "fb711258128c94ffbe6c2ecffa8ae50823ec51c71ad512a2c3df0e56ad82403a87cb1b24e4049f56c065",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 51,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "bf898453344c6875f0b8b9bc2c50fc58e2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "25917d740b0882ba22158b8008ce8c074462c3751413b85e44e86366147c06aa11fa07dd3ee95424d4c8db162cbd164d795e9ddfb2b369b3475c1152c2d3f01b19",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 52,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "9e9d7c166ad3bec856fe5d15d3aacbc53c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "72a9fed7bf44ed9f3d09a812e14ada47",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 53,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "17812c26bba941f3c2ebdb9bb6904c9f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "65a48055639d90cb775b68926e1ad0bfa164d80c010711da0d1ff6838da9d4a7d1aaf28f3b1994f8c50f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 54,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "6c816697ba2366375bb986c0af0d7d20fe",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "17d8c8f2974fbcb8c75109d1660e72fc808aaa96fbd8542a0f51bab2135333e42a5e97c5a96005550aafdba80f78da789d2759e79135a75315baa99f27e8f5f898",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 55,
+          "comment" : "empty password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "",
+          "salt" : "1a71e2118c9fbcc9",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "93f5d4cf0da5cd8c1f2c71a48a8efebfbda76763c29ca535c5a09e627af5c410",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 56,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "523249584467597a5a4271363970667a4a714e744b7761545a4544494676766b6a6253417167566e456a6b456b454557504e69383653626a6e376b725764394d67",
+          "salt" : "d26b99043c8ba3a4",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "de22ce1289402528225f5e52e00dad82b394f1e593d2fa0fa83185137bef5ed2",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 57,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "63727a466d396430795463456a6468545758693877674e516f544e6d486e61686f6956317071613133655471477933497531354b4f52516339494c53646756527a45524e6b4463723565676a62584a78426572536a74726b6b674341616a63356243354434706e66743836663754626663666370595a30767354454d4930524178",
+          "salt" : "9266da5b8c102b27",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "c80288d20baa94340a79735e9257867b92709568e71355209911d7c4ad484621",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 58,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "32647a56696e6f45774464656c656257797058314d6f4f685975585a463172514b7a32535a6c307578576377796f3561416e6f42524e7a5044763072513662693742345a34324f50695258534c6859684441643262746f647633744d54424430744b46316e4b655942656565547a70413145434150713942687a4a4c555a67737636754e4b664450333558414d684a486c736a6f5a796b677130624d506265556941796d6f324371586b64524752633876544176684e5a5838536f564d33704e74594a4a7258766975337547583233736a353847723061614a4b45763765796c373248636e6167713474766e533737626d6376676c79536d347370707a65673869",
+          "salt" : "6a06903b78dae6de",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "90767f81981d82c053146a0f69f04d3035a675ffc0cbca5f5debdccfbe59aa69",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 59,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ff",
+          "salt" : "32140a66b88e1683",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "33d50f1918fd8cc91969b41cf0ac6cb7",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 60,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ffffffffffffffff",
+          "salt" : "8a359634423ed028",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "ced7945a9c40448206dc738a462cabe5",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 61,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "c0c0c0c0c0c0c0c0",
+          "salt" : "d6f596f170ed2414",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "c6d3d7d40940ce6ea88ec034df470722",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 62,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "eeeeeeeeeeeeeeee",
+          "salt" : "6b2269425e288d03",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "d1dd8d5b7f5e74d628bc656b2c4a7e08",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 63,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "f0f0f0f0f0f0f0f0",
+          "salt" : "ebf0b04633711248",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "1cf137fbce42e38f531a4f36ff3717e4",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 64,
+          "comment" : "special case password",
+          "flags" : [
+            "Ascii"
+          ],
+          "password" : "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+          "salt" : "9de9b71eeb9d9a34",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "a7b2fbb97f52f3b5f9006e0f1acf0903",
+          "result" : "valid"
+        }
+      ]
+    }
+  ]
+}
diff --git a/testdata/pbkdf2_hmacsha224_test.json b/testdata/pbkdf2_hmacsha224_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/pbkdf2_hmacsha224_test.json
@@ -0,0 +1,844 @@
+{
+  "algorithm" : "PBKDF2-HMACSHA224",
+  "schema" : "pbkdf_test_schema.json",
+  "generatorVersion" : "0.9",
+  "numberOfTests" : 58,
+  "header" : [
+    "Test vector of type PbkdfTest are for password based key derivations."
+  ],
+  "notes" : {
+    "Ascii" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of ASCII characters."
+    },
+    "NonUtf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is not a valid UTF-8 string."
+    },
+    "Printable" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of printable ASCII characters."
+    },
+    "Pseudorandom" : {
+      "bugType" : "BASIC",
+      "description" : "Pseudorandomly generated test vector"
+    },
+    "Utf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is a valid UTF-8 string."
+    }
+  },
+  "testGroups" : [
+    {
+      "type" : "PbkdfTest",
+      "tests" : [
+        {
+          "tcId" : 1,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7130577430643470",
+          "salt" : "798acc7c76739d75",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "8ee143d436330d9978192aadd0b94620",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 2,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "5a30673349567272",
+          "salt" : "84bbd18de5ec10ff",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "9a1f601c9cbdab8d856ca134226400b7bb76e05f832e6dbed7fbbbb8588f350df805442b5978f5640f52",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 3,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7439315572766f47",
+          "salt" : "5d76db9ca0f0bae2",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "a2f0f558845aa8fd8c5f7c203a59ddd0d58f1887150c2591c2909233f742748728c1cd68444c8f21d109557ed43ce6e9a1d98334069a6cedda77836fef55ad9ebd",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 4,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6b6f67583748447a",
+          "salt" : "0a8fbd0232a3a7f6e60d6564c92ea35f",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "bfd1d1ebc924499214c89c76006da228",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 5,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "4939745242645168",
+          "salt" : "0a3bb93c0de86a174ab005b8089706ab",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "1fc1f840c5b1810f6f01faefe26c895ae6b9f7207dc7cf1add3aed572eaf12bbbaddfe80db5422d50218",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 6,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f4f4d37324f6330",
+          "salt" : "3827c3b6b1e6de7aab096eb75e23610d",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "7444ff45b60c82af7f14f218361734e42b854fb7c1997d1266240392b6bf297f5a99906aa3c5c0156d03ffae52615f85a21ff45bdcb3ae7e5159db15d257524468",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 7,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6761734354484546654d36526f61386349",
+          "salt" : "cac9ae7ef670990e",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "c9448067dc15c12a1550c0b0a0e2a358",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 8,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75375330684678774e33586f76707a6551",
+          "salt" : "201b1e277f4ff955",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "2f4159d45782f53fb33dd1369d3aea4e2374ca4527232b6a9556e710850d5029379bf3f304d7deb46a16",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 9,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "51524b6a6d6d6b48524462507178696868",
+          "salt" : "e71d6a702d2d54bc",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "4d7e0c01e9b1ea0f6a50759994199d9f0044ed9cfc10808ba8412ef2014ef3d2aed990e7a8822e51adca3874fa65e99c7bf73a6f42f75c463d9f865facca832ce3",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 10,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "3732317a52704b79306b4c6d3862317279",
+          "salt" : "8b9314972ef99ac98b69b6c2ac4247c7",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "1852ac8b5be4deaec864b3067568662a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 11,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75507078436d7536577a68535030553947",
+          "salt" : "874d7cc9369ff9988642ffe05a7f2153",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "8b2f2b1e5fcf522011021c828cf26fb17bdbbe158e8af6998c0decddad9be0b9afb5ed20c66f6bc291aa",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 12,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f39505350597a445349783365374d7063",
+          "salt" : "1569909588ec25a3afa147cb7fff9fb1",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "aefe089d5e9ee63c3523e11bbe71eb450a4018aa4e254ed2b2ed87d05a1b3004d384647b7409c6c757c642377b2dd00d9d08056bf593c39397df746bc6f4d16f9e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 13,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "4b2d6c600e66535c7f3d6d6c",
+          "salt" : "7a4c02db8bd8fcc2",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "c49bf50d62342de453882f039bb24f9c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 14,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "287e74200e6b69285f5f7e00",
+          "salt" : "dddf018487fa4b95",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "b40505833bd98dfbf63887c2a3a01b2b6eaf74f2d84eb2c61dca3e7d71518838a8be7d6a4e523ef5b560",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 15,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0f3b5e220e1c000c2a703a4d",
+          "salt" : "7cf71b19c0daf499",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "98c020d02eeaec4b22a02f868ddf3605b71b5cc6f7100290e52d34a9130b0fa216c642bac79880b6cd1a994c4174ec5eb49b006e9af04d2f287e08947c39a1146c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 16,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "1f4b7b354903545f2b07720b",
+          "salt" : "62f91ea01f3a18aa2f122bd15c4a615b",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "e3d468c8fdd6dc804807a5d95c582fe9",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 17,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5d7e1f70174b2f616c0d6722",
+          "salt" : "4fb602173327228431dd8d088154c7ec",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "8bd0fea27f1cfbfe25ac2984555f8df381368988d9f2dbffc22131dd965753afd1da4a53410557c23506",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 18,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2657324e694c6b242b174f1a",
+          "salt" : "3cad67ef1eaf3aaafb5ee372d6b1ecb4",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "aed6d39875469a31c7ad938fd8851332a63a3dacff2b61884cea25f9e81c5610ca9d1c4f1a1afeb05d55bd9109a12ee0a71219aa40525b8cbe6af98dfd9e2f01d7",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 19,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "15296b0933473119180f3c0645202d5221633803",
+          "salt" : "d7412e4137fc4410",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "8d1b5237ed7520cb0027882bd0467f93",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 20,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2a1124361d53464245580130076d6b54001e3d01",
+          "salt" : "7ecaaed1eab03c3f",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "b95ebbc29ead629df25d6533554fbf46647222984c903ff37119b8dacd7aaba8882920de15de74497ed2",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 21,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "666252024e18233308430820127e753e6b041f56",
+          "salt" : "756fafa7fac1f019",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "fe93a237dd6d3b8f0b2b7eacfbe7ded7380459b8178442789a1ebedd07ee2a0e19be10cda8408874c74836f50fe91f40d53313a66af23ed3430a6ecddf1385c100",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 22,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "7a106c2773542c791d2f0a02632b69644f666278",
+          "salt" : "a36350db68186d747254bd9835219487",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "2ed3182fe79f06c9a596fd490b1c017d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 23,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0b3c6f070149615b5b461c7f5c3a366a6d375402",
+          "salt" : "42be6c8fcd7858458fb97cece0069524",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "94414d27ac8d70b9bd2f1bd2b118d2e3e1357d2f1b0bd6362860cf4bb147ccacb7fc3368ec510956bf0c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 24,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5177161d237c1b64426353434113015665344a6c",
+          "salt" : "596dbf5830aa8b00682429883e0b1ed0",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "e7e75e35802873aaab1f30780c20f94e78b170d67b2e4a176021965e625e4627cb3426160fa1d2061c6811f3f72c501fdafe661b694bcb121b51c921574ea2db58",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 25,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0a3d1bed38acc83",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "cb040e12e0eea63ad10d001e89919ac6",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 26,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0bcccab64cf9a",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "6fce6b09ea9b732ac9c8cde746cde750afd085727c5b6e682de5a7d37e675b00b04793f07b06f42c8b71",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 27,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c4bddd8f4ad69d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "8b58d6b6e4713dfab03329c62062a9853773b28c1841f8d30fdb8c6ba784a4670c1597f3e7271dcfdbd92a1ebf568670d4089080ecd5ae2ec9764d9d2ae7a8c9ed",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 28,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c299d292cd92dcb8",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "3c7b934589ba433d0da6e397f132616f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 29,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d086d0bed193d9b6",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "7814fd5d064e12b86cd9e6b61cbb2dca414b1b12e665acc8f6ae7922a5c60113d3fc181457b6733c61da",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 30,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da8ddcacde86d3b0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "2d631f110b6bc4ea1ee462d6039af2f3ac651c4739f9a246eff02bcbccab7a72a854c1a1f6429c4ee1c0d62e0cf56ee5906f989e8f6ded492a33977f233d5d0a6e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 31,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "cfbec3b9d7acc7a9d585d1a5c28fcca119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "9999bb75024ef114c3e0787500c72aaa",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 32,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d29cd099d197d1b0da9aca8ec2a3daa5c38a",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "c74a4ace9c8dec9490adccb6bad6f38a7b36ea30553bf032823c42bb9970a986f409e0c6314f4a82406b",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 33,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de89d193d18c75c2b8c6bcd190d198c3a2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "b4baf5e438c7465049535ea5f183ccc01bf412f515e52b5259a6dbde5627193e94d632411ed16d271e119ea80ebf6c19834c1d956cab22aec7337f11662564d026",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 34,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da9dd096cb93db88dbbed495ceaacf853c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "59dd1197e7721f9f3be4cbdd309e4dfd",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 35,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de81d0a6cea9c7b3cbabce9bda90d29f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "2699a8ac4728c9535b2c094be58ed578c425f7c479e40946728be29c6ab14d52cc03b602b91199a213fe",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 36,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d281da97c8a3d8b7ceb9db80dc8dd4a0c3be",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "33083fc03d8634cd65c9a5839d7cfdcd6b09cf8c003a90ceeb5e3e58ff719a2e1d8f6cec64506c01d159079cb3335197534830a6725efc30b8667aee805afa1d07",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 37,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "8423ec7ea4ca4b03",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "3b84b80b42f43bca914e80164355e852",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 38,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "2c3c932bf0648bda",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "7ce297a6d0588e7c9f6d2d75f44b0a8833f074139048aa0f4e4ab384b6776b60ef1a146eeafd2ab57c3e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 39,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "393d474fd84a259d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "a2320e47599baea5be1c4d5974fbff28d613e7aee0524aac6894e220ae5e168a3d9b9f8998ae4e13342192f682c08a7f3b9461f5c4792c288534765d6aaefae2c7",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 40,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "48997492c3528738",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "a37a0077cd05aa668e786dd304981f9d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 41,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "74064c3ebc53d676",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "b35e1def1113955c4f667c6e2eedf363fbdfa88486f8035223b0e1e3eef0399b543a779cf57dc84a0341",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 42,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "668d4f2c8f86f4f0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "6c8d13d7925f29cbf6987213e4842a7cde9f03ae8802bad7d493864e5f26144a80347e5a54c3ab2884cacd9aa6d41151afdbf31c0335b5b108c12c8b469386f069",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 43,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "e3feb0f96dec61e9dd451465f88f132119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "a4078a7899f2adbab5f3451fcb89340a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 44,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "5c9ca419dc576470269a828e40a3aea5ca",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "350c195ddd613de101173c0601ce650d41ee8e09232567536b66abed5f752b72e07a69dea3c01bf71f47",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 45,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "bf898453344c6875f0b8b9bc2c50fc58e2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "3b9b133ec921e8ea9dc432e688a15bab4217a02b34535f6aeecc3ab53b255e7bf8ec55a21ccc075fdc9b46c6ebefb1d638a8aa7a59f23d397ced4c8bf9946fa31a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 46,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "9e9d7c166ad3bec856fe5d15d3aacbc53c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "28ffe8e4266995d04219ae8d8ed8ccfc",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 47,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "17812c26bba941f3c2ebdb9bb6904c9f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "ae47a0c8b42de24c2198ac5835c40197b7ca16039e35d696757ff9e5c726f3438ea6643bf9b305ec6d9c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 48,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "6c816697ba2366375bb986c0af0d7d20fe",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "55177ff79ba5c91d166676c501298e04697c3aa1d74a6d5d0083885836235b3d1de8b92cfe45a7565aae340389f6fe2954ff8bd2c6385feb07ca66fbdb3e6c60e6",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 49,
+          "comment" : "empty password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "",
+          "salt" : "1a71e2118c9fbcc9",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "07ac722bd16e1c4172d8603276a9e0fa7cf2e571afa7ff758ab00a078716e92a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 50,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "523249584467597a5a4271363970667a4a714e744b7761545a4544494676766b6a6253417167566e456a6b456b454557504e69383653626a6e376b725764394d67",
+          "salt" : "d26b99043c8ba3a4",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "b6222d1ed0a2de7cf974f5781107f2945efce5bae3593df6e4fefd56dbd0708a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 51,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "63727a466d396430795463456a6468545758693877674e516f544e6d486e61686f6956317071613133655471477933497531354b4f52516339494c53646756527a45524e6b4463723565676a62584a78426572536a74726b6b674341616a63356243354434706e66743836663754626663666370595a30767354454d4930524178",
+          "salt" : "9266da5b8c102b27",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "784299cb3c35493271eb7d454afccc6badb739df38ddc7063a5d7b89711e4557",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 52,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "32647a56696e6f45774464656c656257797058314d6f4f685975585a463172514b7a32535a6c307578576377796f3561416e6f42524e7a5044763072513662693742345a34324f50695258534c6859684441643262746f647633744d54424430744b46316e4b655942656565547a70413145434150713942687a4a4c555a67737636754e4b664450333558414d684a486c736a6f5a796b677130624d506265556941796d6f324371586b64524752633876544176684e5a5838536f564d33704e74594a4a7258766975337547583233736a353847723061614a4b45763765796c373248636e6167713474766e533737626d6376676c79536d347370707a65673869",
+          "salt" : "6a06903b78dae6de",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "b7bfe6935e410bf32d784c62286a27c1a2cf2f62aaf24d8c3cd37e530975208f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 53,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ff",
+          "salt" : "32140a66b88e1683",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "faeec67a6a2412e6358bbcf94fd38ded",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 54,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ffffffffffffffff",
+          "salt" : "8a359634423ed028",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "fb035bae860781afee4d914470ea4850",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 55,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "c0c0c0c0c0c0c0c0",
+          "salt" : "d6f596f170ed2414",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "fd42b834d6972af4605a86838471ad82",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 56,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "eeeeeeeeeeeeeeee",
+          "salt" : "6b2269425e288d03",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "3f57172c4cc4da1ccbd022f84c7b82a8",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 57,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "f0f0f0f0f0f0f0f0",
+          "salt" : "ebf0b04633711248",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "645e34ea3e1a8dc4aaf2ffb1f970c3ee",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 58,
+          "comment" : "special case password",
+          "flags" : [
+            "Ascii"
+          ],
+          "password" : "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+          "salt" : "9de9b71eeb9d9a34",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "54fdb38a5a46c597866bdb22b0bdd7b0",
+          "result" : "valid"
+        }
+      ]
+    }
+  ]
+}
diff --git a/testdata/pbkdf2_hmacsha256_test.json b/testdata/pbkdf2_hmacsha256_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/pbkdf2_hmacsha256_test.json
@@ -0,0 +1,876 @@
+{
+  "algorithm" : "PBKDF2-HMACSHA256",
+  "schema" : "pbkdf_test_schema.json",
+  "generatorVersion" : "0.9",
+  "numberOfTests" : 60,
+  "header" : [
+    "Test vector of type PbkdfTest are for password based key derivations."
+  ],
+  "notes" : {
+    "Ascii" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of ASCII characters."
+    },
+    "NonUtf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is not a valid UTF-8 string."
+    },
+    "Printable" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of printable ASCII characters."
+    },
+    "Pseudorandom" : {
+      "bugType" : "BASIC",
+      "description" : "Pseudorandomly generated test vector"
+    },
+    "Rfc7914" : {
+      "bugType" : "BASIC",
+      "description" : "Known test vector from RFC 7914"
+    },
+    "Utf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is a valid UTF-8 string."
+    }
+  },
+  "testGroups" : [
+    {
+      "type" : "PbkdfTest",
+      "tests" : [
+        {
+          "tcId" : 1,
+          "comment" : "RFC 7914",
+          "flags" : [
+            "Rfc7914",
+            "Printable"
+          ],
+          "password" : "706173737764",
+          "salt" : "73616c74",
+          "iterationCount" : 1,
+          "dkLen" : 64,
+          "dk" : "55ac046e56e3089fec1691c22544b605f94185216dde0465e68b9d57c20dacbc49ca9cccf179b645991664b39d77ef317c71b845b1e30bd509112041d3a19783",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 2,
+          "comment" : "RFC 7914",
+          "flags" : [
+            "Rfc7914",
+            "Printable"
+          ],
+          "password" : "50617373776f7264",
+          "salt" : "4e61436c",
+          "iterationCount" : 80000,
+          "dkLen" : 64,
+          "dk" : "4ddcd8f60b98be21830cee5ef22701f9641a4418d04c0414aeff08876b34ab56a1d425a1225833549adb841b51c9b3176a272bdebba1d078478f62b397f33c8d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 3,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7130577430643470",
+          "salt" : "798acc7c76739d75",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "0501c73cb9f40b6769513e70e525051e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 4,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "5a30673349567272",
+          "salt" : "84bbd18de5ec10ff",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "05fd57d1cc373fa9f37e1857ac1c0af8fbf635e139a42f9dd25a4e4b4698ea13e943f42220384d32a272",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 5,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7439315572766f47",
+          "salt" : "5d76db9ca0f0bae2",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "2a3974a8071f005997c00d33812d8cf52e6af76a7ac558bf5dedeb296464bccf696ad753a79eb7b1b21738584a58d03d2b6c2b7bda08788f844655f6a90b0e3444",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 6,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6b6f67583748447a",
+          "salt" : "0a8fbd0232a3a7f6e60d6564c92ea35f",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "c81676dbeb95582c66c3fc2636f1cb7f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 7,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "4939745242645168",
+          "salt" : "0a3bb93c0de86a174ab005b8089706ab",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "b7a44e0e93f5f7ec0e177292db0b4a1354b62709e0dfe02167e19586fa99a70d777b39f953fb4fa4c342",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 8,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f4f4d37324f6330",
+          "salt" : "3827c3b6b1e6de7aab096eb75e23610d",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "907bc6a107df5940fb4c986b3f1ed888bcb0c41462df94b58de682c5627c1125492fd3091b06d83ce09794edad3776adb107994f671efe7b3ccd1d3a8fdcb444cc",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 9,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6761734354484546654d36526f61386349",
+          "salt" : "cac9ae7ef670990e",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "2a9fc926e220c7915ccd8cde2916ae87",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 10,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75375330684678774e33586f76707a6551",
+          "salt" : "201b1e277f4ff955",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "0dc0835f4b34eb966f4cf0d662c020eb09b733c7baa78abe2be41a01a1ccad60aad4969ac3fbcb643511",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 11,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "51524b6a6d6d6b48524462507178696868",
+          "salt" : "e71d6a702d2d54bc",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "2cdbfdc4f06e4d0e743a49ca459eff921b2eaf70e5d69b3c913239c56376c78c47ab66927c322612ad97cb1c824ae425aaefc6647db405dfab3a89cfd2055a0f4c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 12,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "3732317a52704b79306b4c6d3862317279",
+          "salt" : "8b9314972ef99ac98b69b6c2ac4247c7",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "0da1ed7920894303780ee9a34fb6fa2f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 13,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75507078436d7536577a68535030553947",
+          "salt" : "874d7cc9369ff9988642ffe05a7f2153",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "35367e9ce58daa37e51883e9828204d438ede1065acf9933dfee1aae0ac7b24b46ae0c2c16885b809e6b",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 14,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f39505350597a445349783365374d7063",
+          "salt" : "1569909588ec25a3afa147cb7fff9fb1",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "7afa4394324fdfe23b879a8fb932c908217e4368ee5ee8dccac2fe2d2f62c12e9f48620c50b7bdb05448c64987b10df54ff5012e9a5064e9f92c51cc7b635d270d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 15,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "4b2d6c600e66535c7f3d6d6c",
+          "salt" : "7a4c02db8bd8fcc2",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "b05992b03b7f25b4a24a27e98cdfff3a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 16,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "287e74200e6b69285f5f7e00",
+          "salt" : "dddf018487fa4b95",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "89d809be903d632abd1b29130229b3a837bb4943aafb86a520498fa1814cd3f82f0b6c7db8d09d19c439",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 17,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0f3b5e220e1c000c2a703a4d",
+          "salt" : "7cf71b19c0daf499",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "5f3110ce1e653e086b289eb972ddd496b44884b7eab0361d0e9a39426b1d977582bd32b84ae1a204a51dbd458757489e01928dbcced2397109a1470d8383804099",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 18,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "1f4b7b354903545f2b07720b",
+          "salt" : "62f91ea01f3a18aa2f122bd15c4a615b",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "d4bbf153661b4516d740535b37780aaf",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 19,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5d7e1f70174b2f616c0d6722",
+          "salt" : "4fb602173327228431dd8d088154c7ec",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "8216132b4952f8be46586e22ce961b597671f9172027e36f2c4cbd189dcaf719981a9c03186d5faadd80",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 20,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2657324e694c6b242b174f1a",
+          "salt" : "3cad67ef1eaf3aaafb5ee372d6b1ecb4",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "7103c69766b64bae721776ac272db586c7ba3c4775e33e1f37a24fad51e599d56d344e4566d48928f3c1c52b91642b10c86a29d3d2490eee2889c2899802e38c82",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 21,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "15296b0933473119180f3c0645202d5221633803",
+          "salt" : "d7412e4137fc4410",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "1c0b23dfd1a3ea727c5c596ca840b393",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 22,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2a1124361d53464245580130076d6b54001e3d01",
+          "salt" : "7ecaaed1eab03c3f",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "c0f82935e509c96ad0edf9aa2db243cb81cec0c047d5141915a58ce114c119290228f318a9c5dcd518f6",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 23,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "666252024e18233308430820127e753e6b041f56",
+          "salt" : "756fafa7fac1f019",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "d571b3006053ce3060ddfdbcf3c782d834e49701d4c745f3a282618cacdff4c73cadb7e719de641860ab0ffc860518d0bf02cec16ca83c26f4df993050ee67f2f7",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 24,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "7a106c2773542c791d2f0a02632b69644f666278",
+          "salt" : "a36350db68186d747254bd9835219487",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "5f0af11697779ae0530fab32806f2184",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 25,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0b3c6f070149615b5b461c7f5c3a366a6d375402",
+          "salt" : "42be6c8fcd7858458fb97cece0069524",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "88d3744fae380954ae5875eca20b998e0d0c991716a8ef90cc1e547d489415ce6f8310742defcf4ca6cd",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 26,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5177161d237c1b64426353434113015665344a6c",
+          "salt" : "596dbf5830aa8b00682429883e0b1ed0",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "d8e5a55f92ba0d6da87765c653c82f080472260640e59ebe7d003baca444bdbe553156e5c365be28fc0acb7927e5c9789cdf6b481aa1c39c444a0cfc8b6e475182",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 27,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0a3d1bed38acc83",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "c06bc1f9a63de10fe5f6936991477029",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 28,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0bcccab64cf9a",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "6a6287564b66b1f08bfabdfe2907b870b8c6ad8071efd78a7302c9472d7b80fb50c662bf609827006831",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 29,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c4bddd8f4ad69d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "7e52e8694056266a7da6527dac089f921298dfc497020eecee58d73fd123deb97cf3c2f532505468afc17e6e283ba6b68afcd5e6881be17e1647d3ab491a5589b1",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 30,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c299d292cd92dcb8",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "fcfa00ce7c0249542e6f3a40ef0a86d0",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 31,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d086d0bed193d9b6",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "46abf02c99b9f6d86fbf2e11bf0642b658a82a9e034ddea40df78a84bbc4b1e1e9f5bfbc55b12fba4545",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 32,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da8ddcacde86d3b0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "4438d49f0cd0ff7026fa6283b25bac570e1c845eaccdd80060e896f5420ee54533eb649a6fd9d99d29ceb7dcb18d059d422840aef7d4452d888f59bdd760b5db73",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 33,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "cfbec3b9d7acc7a9d585d1a5c28fcca119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "a9a7c8ade1041532e085ff65c35f28e8",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 34,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d29cd099d197d1b0da9aca8ec2a3daa5c38a",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "e34b5cf037bec44307bb8a60bbdc7d5f19badd15825150ec453adb490b947c3c1bc4132ec2217be47f60",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 35,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de89d193d18c75c2b8c6bcd190d198c3a2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "d9ac15032443c7ed2678a07849c65897a2625f367fee614895c6f4f655094721024342ad24f0fa654bbd1abc7d01993ab21120da4e6f5b36a67885462d14442732",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 36,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da9dd096cb93db88dbbed495ceaacf853c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "230bf1d40a4ccfe9a1b5241a8266cef9",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 37,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de81d0a6cea9c7b3cbabce9bda90d29f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "978fd90caf8646ef894d8c9ffe19d20f0f0dfe80cf5bfe8464385bc0552e226a82b1fa764e8198ffda9f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 38,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d281da97c8a3d8b7ceb9db80dc8dd4a0c3be",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "92b10b7d996d6e3f5d8e7e2c1075cc89c837b85cb72aff0092449b256254591178a6bf7fdfe3742f5fe5e402788058f4f90efc9cc9e9e7547b27bd4d34da333314",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 39,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "8423ec7ea4ca4b03",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "5425a8ac3db02f9675331c0560a7b641",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 40,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "2c3c932bf0648bda",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "9eb4049ccee01488f15af3b988ef6d4817fce166686c17a7ea1cdc8bc548883ed1d53df6439fecda324b",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 41,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "393d474fd84a259d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "a6d3a1636fe688f9a0aef5050e749c9f641669e6b953958263752382cb324de125315c4f340464c935e15e60dfa61fc8e8cd15386b2ed9894fad8b327338dc91f7",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 42,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "48997492c3528738",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "2b7d2346fbb52fbff8881db235132319",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 43,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "74064c3ebc53d676",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "a97c2fb6bd6a91470d78af24ee8f0f9e783517ba0396db031a46640c8436368ba9b5d0e048952bb2a6f0",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 44,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "668d4f2c8f86f4f0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "8a4399e9487079701b079b6ad3587ff62593ff75c36629268e0cc2f88089203ac27a81ef9b4fa9ab6db5e220fca8f3d46c2b8c5c4d1149d461d1cd2c0884496608",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 45,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "e3feb0f96dec61e9dd451465f88f132119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "a2641a7a323435e0443cfd8dd60a536e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 46,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "5c9ca419dc576470269a828e40a3aea5ca",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "df3c139e07076d5e37d9dd0b59164903830d8fd1c4cb0b3189a5f97cd73b66f8066c31630bb44e705d9f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 47,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "bf898453344c6875f0b8b9bc2c50fc58e2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "bb14e909b953a6576fdd607ccb8ee2d69fc1f9458ae597010c08a17094d64beb156f3db86203d441b7a2a192d75fb8f8ce78baf6f059f3b982a1e1ea32a6f1a958",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 48,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "9e9d7c166ad3bec856fe5d15d3aacbc53c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "38a92f260cc84a8eb5943df1f19f64a1",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 49,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "17812c26bba941f3c2ebdb9bb6904c9f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "a410ebcad1e884660353e7324d04f9f0e68ea3bb925fefe32b86c8f590029d63f9abc5c34c9a7b07845f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 50,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "6c816697ba2366375bb986c0af0d7d20fe",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "2577e802465d3e309dc76be377bbabdc8e59838d9f4f0a23ccd67f118094a851edd9a6ab4d332d8927bb0b77e71da28d6f5db78b2196758340ebf25e74cf681a1f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 51,
+          "comment" : "empty password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "",
+          "salt" : "1a71e2118c9fbcc9",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "3e513d89ea5ad303f17cbf7cbdea54a940f0f5811844dfa875a55a8241d2f8df",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 52,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "523249584467597a5a4271363970667a4a714e744b7761545a4544494676766b6a6253417167566e456a6b456b454557504e69383653626a6e376b725764394d67",
+          "salt" : "d26b99043c8ba3a4",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "c8595fa30dc95fb839bebfcc230f06844b2f75a393570b22d6c14d647837b87a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 53,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "63727a466d396430795463456a6468545758693877674e516f544e6d486e61686f6956317071613133655471477933497531354b4f52516339494c53646756527a45524e6b4463723565676a62584a78426572536a74726b6b674341616a63356243354434706e66743836663754626663666370595a30767354454d4930524178",
+          "salt" : "9266da5b8c102b27",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "24a86f12235e0232bc80a84635a43934b2d37ae1120b4aa1728a3ead93868980",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 54,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "32647a56696e6f45774464656c656257797058314d6f4f685975585a463172514b7a32535a6c307578576377796f3561416e6f42524e7a5044763072513662693742345a34324f50695258534c6859684441643262746f647633744d54424430744b46316e4b655942656565547a70413145434150713942687a4a4c555a67737636754e4b664450333558414d684a486c736a6f5a796b677130624d506265556941796d6f324371586b64524752633876544176684e5a5838536f564d33704e74594a4a7258766975337547583233736a353847723061614a4b45763765796c373248636e6167713474766e533737626d6376676c79536d347370707a65673869",
+          "salt" : "6a06903b78dae6de",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "a50be9c16f6bf68808436aa3bc6eec36d3c5653c9c7510c1a4a641755b8325fb",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 55,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ff",
+          "salt" : "32140a66b88e1683",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "49bc8d940c8f67ae5ee0764f59dea94e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 56,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ffffffffffffffff",
+          "salt" : "8a359634423ed028",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "00ef53593c10c8986f36838017194c2c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 57,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "c0c0c0c0c0c0c0c0",
+          "salt" : "d6f596f170ed2414",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "b81cd442c5aa1b23aee69225a501cc70",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 58,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "eeeeeeeeeeeeeeee",
+          "salt" : "6b2269425e288d03",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "a80f332f10dfc0e4380b2fac8449fe77",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 59,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "f0f0f0f0f0f0f0f0",
+          "salt" : "ebf0b04633711248",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "a516a9d9e5d310227dc19ef15357b4b1",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 60,
+          "comment" : "special case password",
+          "flags" : [
+            "Ascii"
+          ],
+          "password" : "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+          "salt" : "9de9b71eeb9d9a34",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "5869f35bb108f1c45605ca8109e6661d",
+          "result" : "valid"
+        }
+      ]
+    }
+  ]
+}
diff --git a/testdata/pbkdf2_hmacsha384_test.json b/testdata/pbkdf2_hmacsha384_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/pbkdf2_hmacsha384_test.json
@@ -0,0 +1,844 @@
+{
+  "algorithm" : "PBKDF2-HMACSHA384",
+  "schema" : "pbkdf_test_schema.json",
+  "generatorVersion" : "0.9",
+  "numberOfTests" : 58,
+  "header" : [
+    "Test vector of type PbkdfTest are for password based key derivations."
+  ],
+  "notes" : {
+    "Ascii" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of ASCII characters."
+    },
+    "NonUtf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is not a valid UTF-8 string."
+    },
+    "Printable" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of printable ASCII characters."
+    },
+    "Pseudorandom" : {
+      "bugType" : "BASIC",
+      "description" : "Pseudorandomly generated test vector"
+    },
+    "Utf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is a valid UTF-8 string."
+    }
+  },
+  "testGroups" : [
+    {
+      "type" : "PbkdfTest",
+      "tests" : [
+        {
+          "tcId" : 1,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7130577430643470",
+          "salt" : "798acc7c76739d75",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "c5a4f853b1a3960134e572c8e29a5be9",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 2,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "5a30673349567272",
+          "salt" : "84bbd18de5ec10ff",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "dfe1dea643acbae0d7d3154717f9a620937d9bf32218b27db99c0c34a0a6dfa189b31292020b727cc147",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 3,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7439315572766f47",
+          "salt" : "5d76db9ca0f0bae2",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "17c6ba7e45f8a26a13b4d5f72ca3a2f97147e5f60c3108829b5b51633ab8afd9888b0465b22995f072ee2c8383e091afb808bf48b0e786da661ff95142a6229f1f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 4,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6b6f67583748447a",
+          "salt" : "0a8fbd0232a3a7f6e60d6564c92ea35f",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "7b3772edd5ddd2fefed54e124f813d15",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 5,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "4939745242645168",
+          "salt" : "0a3bb93c0de86a174ab005b8089706ab",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "314c435ffba5e7f8dc3af254ac164c51398c839f3c789a91fb7927aa28dc2424bc589e29fd715fe74bba",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 6,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f4f4d37324f6330",
+          "salt" : "3827c3b6b1e6de7aab096eb75e23610d",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "409b051d5d6f77149633e65dfd0ebbadebda7f82ffcc09d2efb81c6d94a425537c5bb1bea65a9fb40a92a4466bfb448d7b3505894dccdb6a365d44d4eba54ef11c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 7,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6761734354484546654d36526f61386349",
+          "salt" : "cac9ae7ef670990e",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "9df584f636d3853cf75116538f240315",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 8,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75375330684678774e33586f76707a6551",
+          "salt" : "201b1e277f4ff955",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "74d040278a45fe589c66833a34cc71bc7249b4dc86e3dc70117afb94cf7cb60bca51c1d3359a06998c95",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 9,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "51524b6a6d6d6b48524462507178696868",
+          "salt" : "e71d6a702d2d54bc",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "421a7d2aa3a0b31f51b2971af45327ffbcb023a6fd71a51739e57b335c9fe386f8b3083991bc012474161341ebb71f7b9f495d012904264aacebc00a6392b7e81c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 10,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "3732317a52704b79306b4c6d3862317279",
+          "salt" : "8b9314972ef99ac98b69b6c2ac4247c7",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "5873f6050f94ffbd5db5913711a1e779",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 11,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75507078436d7536577a68535030553947",
+          "salt" : "874d7cc9369ff9988642ffe05a7f2153",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "38c6c68ab0ea2aab6e8ec42b6d08203e7f3c2a51c7771626b9df6ac6ac0e7c7b9e690ea53916e7a4ef0a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 12,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f39505350597a445349783365374d7063",
+          "salt" : "1569909588ec25a3afa147cb7fff9fb1",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "8b3c1f69e3e992203f59b4aee9c0ed79d98bf7a1f261036d58e2289dcde27504129d357f8c21919f1f152e7d4a5c011e04f1adf2a3d0fdcb64cad396810332d291",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 13,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "4b2d6c600e66535c7f3d6d6c",
+          "salt" : "7a4c02db8bd8fcc2",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "60f6a456e9a4f1d8dcfb7a73c58ab3be",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 14,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "287e74200e6b69285f5f7e00",
+          "salt" : "dddf018487fa4b95",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "745ff4750e9371a28424220ea555138fed621e4b32c58503720dd1e2243803848f9fe9b74ed09afe759f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 15,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0f3b5e220e1c000c2a703a4d",
+          "salt" : "7cf71b19c0daf499",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "9531ee2380908f10dd0806782a90dd01a513880e6d848ce8370e5a6dff7a061d6e20a2b1ecccadfed5feaae62fa64e0cc6fe98bd87f6311fbc624453344e694a94",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 16,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "1f4b7b354903545f2b07720b",
+          "salt" : "62f91ea01f3a18aa2f122bd15c4a615b",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "ea4658ca124f8a8332e1241da381e22e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 17,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5d7e1f70174b2f616c0d6722",
+          "salt" : "4fb602173327228431dd8d088154c7ec",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "7b89d1bd20dec87b8ecf9bae12fa0e5d9c0f1c010078249baeefded4f23e354a5fe9efa50d2d569ee12d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 18,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2657324e694c6b242b174f1a",
+          "salt" : "3cad67ef1eaf3aaafb5ee372d6b1ecb4",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "08968b88460e0df143e1f52d0b1640e77266204bb552d3e9a260dfc782aef06707f7eb386a3037dc343792b6c41452549d812eca04be8bf01d436e1557bec6438d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 19,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "15296b0933473119180f3c0645202d5221633803",
+          "salt" : "d7412e4137fc4410",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "23055ea847137568a2ea8f84ae13d220",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 20,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2a1124361d53464245580130076d6b54001e3d01",
+          "salt" : "7ecaaed1eab03c3f",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "819a1fd3f182702d3629b0453352e7224048ed964e57b252a9a039c1d73292f2f668429a8aa530732edc",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 21,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "666252024e18233308430820127e753e6b041f56",
+          "salt" : "756fafa7fac1f019",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "ba344a8df0cbedced95d6f5f8ea8f4d3e1aca52f751a2c1b529067df28d0e3d08ea42ffb6b8f8537dab32ce89ec0dd4a84c7683b5960d987ab7f5f744c7d8d7f84",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 22,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "7a106c2773542c791d2f0a02632b69644f666278",
+          "salt" : "a36350db68186d747254bd9835219487",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "d5ce40d19ffa0ea0a0f9ede719053fcd",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 23,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0b3c6f070149615b5b461c7f5c3a366a6d375402",
+          "salt" : "42be6c8fcd7858458fb97cece0069524",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "840512f3702d1387ab28785750cca03d0452dcd3abbf39f56fa38c04dfe6e1e17570463a8cc074850070",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 24,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5177161d237c1b64426353434113015665344a6c",
+          "salt" : "596dbf5830aa8b00682429883e0b1ed0",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "f9e10e6fc595e3ae155910f4c019245b559bbec1a944de00bbbe1fe202265db4ccf28b9f10b519c1856e3386a15ba9005e05cf5e050718d4a92c89dc309f167a31",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 25,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0a3d1bed38acc83",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "ae75e7ae9848e3550cf3ddfca4ed5e74",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 26,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0bcccab64cf9a",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "9b909246d2fb6e76a5375bcd608820f855e43b7782fb7aecfedc766fbd7296a99862c4ee9ffe52d2305d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 27,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c4bddd8f4ad69d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "555ab346c29248f24748dd7cb9ca4d7793894cf0ffdf337c01c6badf5ea8821eac516b7a93a74628be32468ecad35bc28bf422bcee9046ed9d5512765d644edf05",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 28,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c299d292cd92dcb8",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "84d0773faa5dad4dbbd7d7f461272807",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 29,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d086d0bed193d9b6",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "58b8fd680c076f23f4e65bbb58a4af6a7160c4a8d81c99fb05f0543dd01923dcaef67a8ac6f14e6e1019",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 30,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da8ddcacde86d3b0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "fca2e866aa3e4f42abe27feb90a55286bbd868c93bdd67029db96c77bf943eb094c9e31c55f108bd848824e0ef0bc69e763cbd7c3c6ddc008703a40bcbc8e22667",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 31,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "cfbec3b9d7acc7a9d585d1a5c28fcca119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "7612061da9d4dd1ee3638ab60eae5605",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 32,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d29cd099d197d1b0da9aca8ec2a3daa5c38a",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "845d37414eb3bba79503226c55112a62f787d88aae3f027c0028a06f63d7307dcf9c1c7cd8f255755562",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 33,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de89d193d18c75c2b8c6bcd190d198c3a2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "94fa0a0e7150e8553aef53c3d695f8ef65b3e54c056ed1d0a0c46a064cb5891f4bdff7ee37f21136fb482dcb13421fdce7408b2cd18b9831dc11143aacfca500f0",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 34,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da9dd096cb93db88dbbed495ceaacf853c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "33f720ca3aa8e6784f93d9d04983f88a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 35,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de81d0a6cea9c7b3cbabce9bda90d29f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "5b043b06c042f0ed61f00d15b4d058425ecb822505d141757ad2b428f1ef71dbdb2bca191b7d7b5704b9",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 36,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d281da97c8a3d8b7ceb9db80dc8dd4a0c3be",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "67aee62bffe2d7742a2058bb146ba629f532e2c0576555f0fb385ae8ea7b34e2f8d8b24dd31ea3a14ec6f39138337771d86518d3d1742eb3a5c197d4732012a338",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 37,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "8423ec7ea4ca4b03",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "de49fbeb68b9e092e952fb2f752b642b",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 38,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "2c3c932bf0648bda",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "49b18e7cf5f8803bc129c0f433de931f7e2c3ac1f3305789495ec3548787cee83c1c973f73f4bac9e80b",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 39,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "393d474fd84a259d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "f3a909587aacd3fd03e7fb4a48ece960d90a7a65b36f5b8f982f14f0997a4630e01cf092573a9af728f46e86b35099fe68ccab07a111b832718bd224af8a130194",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 40,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "48997492c3528738",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "c26c4d099817ab48baeb2ed94a6e0e66",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 41,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "74064c3ebc53d676",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "2790b6ca89a36f6702f3fe2a031fbf5349613dd756b8e23e545180286b7f94746eb8df4ee00536a902e1",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 42,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "668d4f2c8f86f4f0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "44fbc1a0422401ed1b4f7a3cccf4c56ed6dd61bbca31ffc542e2930ea62b5c6deeff8cc9c709b2e96a5bcc3ef5b47fec89a0fad774ef5a406fcd319a374dcbf7db",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 43,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "e3feb0f96dec61e9dd451465f88f132119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "81c0ff3ddfee0fbb1c285ea772b21a93",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 44,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "5c9ca419dc576470269a828e40a3aea5ca",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "a54129741f4d41c198e76bc688de03d221bee45538cdb9f265859af918a4b336f6fc9b9f9a0b44894107",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 45,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "bf898453344c6875f0b8b9bc2c50fc58e2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "d88a85b941a802f48a4fa80ebe4ca10e659d684a6310975dff862819930b60f0d776b728a716ef3b7abaea89c9b0fdcbb76c1b9fac44ad28d468800bbb20c7a76c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 46,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "9e9d7c166ad3bec856fe5d15d3aacbc53c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "1a7db5ce7b3fa690dda6aed94b42a07d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 47,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "17812c26bba941f3c2ebdb9bb6904c9f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "41f0fdc44e8e51be0bf913af19524a6d041d6160194226fc90c17da715b4f7b6859060d72d8973bfa66d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 48,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "6c816697ba2366375bb986c0af0d7d20fe",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "81fb39ec6f23c4fd32fc923cea3fbfa42f5b10620f3f0c0716e71301d0886a1392f50ee0e7805964f44bc08abce926002a36fc7806828e7da93cf8ddb910a21e60",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 49,
+          "comment" : "empty password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "",
+          "salt" : "1a71e2118c9fbcc9",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "221029235aaa16453fc00ce61bb81d2c0fe24e8a038dbf845578f7d3aa1006d9",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 50,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "523249584467597a5a4271363970667a4a714e744b7761545a4544494676766b6a6253417167566e456a6b456b454557504e69383653626a6e376b725764394d67",
+          "salt" : "d26b99043c8ba3a4",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "6aff25d08e9acf0bc81366c88c2939b2206a5f3e96a6ae1bb7754286edd72fb5",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 51,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "63727a466d396430795463456a6468545758693877674e516f544e6d486e61686f6956317071613133655471477933497531354b4f52516339494c53646756527a45524e6b4463723565676a62584a78426572536a74726b6b674341616a63356243354434706e66743836663754626663666370595a30767354454d4930524178",
+          "salt" : "9266da5b8c102b27",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "b967e5eb79c1ec4a3ad0cb09c18915793bd87b51911a5e9ca43cbe49eb328b83",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 52,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "32647a56696e6f45774464656c656257797058314d6f4f685975585a463172514b7a32535a6c307578576377796f3561416e6f42524e7a5044763072513662693742345a34324f50695258534c6859684441643262746f647633744d54424430744b46316e4b655942656565547a70413145434150713942687a4a4c555a67737636754e4b664450333558414d684a486c736a6f5a796b677130624d506265556941796d6f324371586b64524752633876544176684e5a5838536f564d33704e74594a4a7258766975337547583233736a353847723061614a4b45763765796c373248636e6167713474766e533737626d6376676c79536d347370707a65673869",
+          "salt" : "6a06903b78dae6de",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "b94a84f5e52d232868f5fe8f4c568ad582e7bd623eab7b7a4f4081f45ab2c7ea",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 53,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ff",
+          "salt" : "32140a66b88e1683",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "bb50b900e301b23a4801bba57e163b47",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 54,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ffffffffffffffff",
+          "salt" : "8a359634423ed028",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "08f89be37ee7d29f9c24c67a354effcb",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 55,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "c0c0c0c0c0c0c0c0",
+          "salt" : "d6f596f170ed2414",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "956f188642dfc807cb47658c203093e5",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 56,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "eeeeeeeeeeeeeeee",
+          "salt" : "6b2269425e288d03",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "c8b50d274f16cd66fce052a0e52f6840",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 57,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "f0f0f0f0f0f0f0f0",
+          "salt" : "ebf0b04633711248",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "5f4d98d86b3de83fe6a554c139256037",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 58,
+          "comment" : "special case password",
+          "flags" : [
+            "Ascii"
+          ],
+          "password" : "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+          "salt" : "9de9b71eeb9d9a34",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "8458ae1b33e367c9724db9259bc06208",
+          "result" : "valid"
+        }
+      ]
+    }
+  ]
+}
diff --git a/testdata/pbkdf2_hmacsha512_test.json b/testdata/pbkdf2_hmacsha512_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/pbkdf2_hmacsha512_test.json
@@ -0,0 +1,844 @@
+{
+  "algorithm" : "PBKDF2-HMACSHA512",
+  "schema" : "pbkdf_test_schema.json",
+  "generatorVersion" : "0.9",
+  "numberOfTests" : 58,
+  "header" : [
+    "Test vector of type PbkdfTest are for password based key derivations."
+  ],
+  "notes" : {
+    "Ascii" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of ASCII characters."
+    },
+    "NonUtf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is not a valid UTF-8 string."
+    },
+    "Printable" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password consisting of printable ASCII characters."
+    },
+    "Pseudorandom" : {
+      "bugType" : "BASIC",
+      "description" : "Pseudorandomly generated test vector"
+    },
+    "Utf8" : {
+      "bugType" : "FUNCTIONALITY",
+      "description" : "The test vector contains a password that is a valid UTF-8 string."
+    }
+  },
+  "testGroups" : [
+    {
+      "type" : "PbkdfTest",
+      "tests" : [
+        {
+          "tcId" : 1,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7130577430643470",
+          "salt" : "798acc7c76739d75",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "4935390897319c3efc15d19304109c79",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 2,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "5a30673349567272",
+          "salt" : "84bbd18de5ec10ff",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "d1b8e64e3b67a548c8bda3118eab1bc3f81b4cc9ead842eda18f6685533c2c7f4f49b8c7a78fea13b776",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 3,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "7439315572766f47",
+          "salt" : "5d76db9ca0f0bae2",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "a5d7f0fe4adc54e2ac5edc54e005827a90cbd46c00b72be68f8fbd1da98c079b98622a69b1ea44c0d94cdae03c339b742d047ac63cac0d9af59786baee4a158080",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 4,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6b6f67583748447a",
+          "salt" : "0a8fbd0232a3a7f6e60d6564c92ea35f",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "23cb042249cf5d03f0cbfe8726df1bee",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 5,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "4939745242645168",
+          "salt" : "0a3bb93c0de86a174ab005b8089706ab",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "8c9a9843f6c108ad5b95d872bd93f1afd8001871e24ae03100ab33a47b3bf923de1e0bb4a95985563a87",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 6,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f4f4d37324f6330",
+          "salt" : "3827c3b6b1e6de7aab096eb75e23610d",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "0159b91e667079e448ae9707f9d538fcc4f297165f6fc85b2966f951669600ee7553f2b80e72f7c2688f2f4cf0f9f7eaf070333987aa7f7d45843076e1708e1229",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 7,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6761734354484546654d36526f61386349",
+          "salt" : "cac9ae7ef670990e",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "4f423de7d3ce6dde25a9c88058f175d4",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 8,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75375330684678774e33586f76707a6551",
+          "salt" : "201b1e277f4ff955",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "f5161add0381cd85fd3816902a56f1e5f84349362d9780eca06c201d1aa976106f5c55146c06d33d131d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 9,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "51524b6a6d6d6b48524462507178696868",
+          "salt" : "e71d6a702d2d54bc",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "8d4c0251d0344406a8e31261046fb4f2bdd7ef402afa5c4eb5020afc2d516ce2ceaf84a2fb904737234fdfe1b2226c5f64ec5a106ef18e9571a53b0db6a9136f43",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 10,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "3732317a52704b79306b4c6d3862317279",
+          "salt" : "8b9314972ef99ac98b69b6c2ac4247c7",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "68b052397d9d0009393f939d8a41030a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 11,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "75507078436d7536577a68535030553947",
+          "salt" : "874d7cc9369ff9988642ffe05a7f2153",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "f6db4aec63695dfa52aa449120b118ef399ed5efa73c3f59c7aa08cd3cd0aa4401634a7bbf61d753752c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 12,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "6f39505350597a445349783365374d7063",
+          "salt" : "1569909588ec25a3afa147cb7fff9fb1",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "b8b2e382821089b02c972c1ca1495bc8d021fabe77733335555c6b27c4a6e73098e8385055891e8a55c05be3d20279bb168b8011f60705928dd05d1451775b62f1",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 13,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "4b2d6c600e66535c7f3d6d6c",
+          "salt" : "7a4c02db8bd8fcc2",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "d3aca4f6df6e06b97c44fd295ed07965",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 14,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "287e74200e6b69285f5f7e00",
+          "salt" : "dddf018487fa4b95",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "1fe411d3bde75e596197040e192dd0aa56ba282125db6e91090caace7bef3b07bbd4d3170b3ca8548587",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 15,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0f3b5e220e1c000c2a703a4d",
+          "salt" : "7cf71b19c0daf499",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "bcc6ca2f82450f4bff09009af28215e68b01ccfcaabdf176a7eceb61974cdd9a9ce3bd10b1d7f781c4d82612fb42bf3db424912389a0727515fef785d2cc28275c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 16,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "1f4b7b354903545f2b07720b",
+          "salt" : "62f91ea01f3a18aa2f122bd15c4a615b",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "26bfec4392af75d5e912d47c7d911fc5",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 17,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5d7e1f70174b2f616c0d6722",
+          "salt" : "4fb602173327228431dd8d088154c7ec",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "21f15760b44d879949320ecfa6fb1023c15f6284127c985ce36d83de2fa6e25fe5f98e0e0bcb89acda3f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 18,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2657324e694c6b242b174f1a",
+          "salt" : "3cad67ef1eaf3aaafb5ee372d6b1ecb4",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "26c4a80bfa48325beefc3a32c543b1ed9dcb67458dba5c41b9ec1ea87ba82f52a98beaf2578f1e1d6f781676bc4664e9b629250bf0ad8440f8ceb05c51101ae8c7",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 19,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "15296b0933473119180f3c0645202d5221633803",
+          "salt" : "d7412e4137fc4410",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "49fa49e9d9d4b8a230b888550fa95d4e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 20,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "2a1124361d53464245580130076d6b54001e3d01",
+          "salt" : "7ecaaed1eab03c3f",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "d80c7b2a6c4bfeede1926ad9fac65400078b865bc02d145fc0e045fe37cbbde4cafad121e4935f0b3052",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 21,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "666252024e18233308430820127e753e6b041f56",
+          "salt" : "756fafa7fac1f019",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "ca4168cd34fd8bf311c180bcf00d7c50482c89f9bd639f8e3217ebcefc99f7cc4bf7fce90a9faed87912293163d5bdb55a61566bccc7366195d72b76f3b26ad332",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 22,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "7a106c2773542c791d2f0a02632b69644f666278",
+          "salt" : "a36350db68186d747254bd9835219487",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "878ede7d7b80d4dfb2d7bffba76b288d",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 23,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "0b3c6f070149615b5b461c7f5c3a366a6d375402",
+          "salt" : "42be6c8fcd7858458fb97cece0069524",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "4d631b173437810b9dc991a9b81d2e8a4981cfed6fd6ae508db3f6f6bb4d267388741ead7d3272fd5075",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 24,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Ascii"
+          ],
+          "password" : "5177161d237c1b64426353434113015665344a6c",
+          "salt" : "596dbf5830aa8b00682429883e0b1ed0",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "a4647786a04fa4129f5c4c43ff7ca48362b9e25bc962345355f01036c5df2962fab2906218267bd04b11a1834abb965e861e2fa3d721e4dded92ed7eefed058609",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 25,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0a3d1bed38acc83",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "6b62ce6976fe2c9bd0a8a7acba7b83b5",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 26,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d0bcccab64cf9a",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "3b80d72751093ad48c08b70fd13308834078b52c3c14e47523e189a475b6f6678dafb991bcb5c0b73f2c",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 27,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c4bddd8f4ad69d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "4bb5ebbe99297ac9a3e3b14ad1a5b52307fbdb991e9bae74561a93e7165086f4a198ad515be73ccda118168150ece8627199ff267c3132e3a556ba3d8d812d8392",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 28,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "c299d292cd92dcb8",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "876ae3968ee80397415e7137d9bddf76",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 29,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d086d0bed193d9b6",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "a0d55ac130b1ef52e4dc05203cb78304cc49f56dd165eafc51f009d44cddf6e4befa59d3231c2393e9ab",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 30,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da8ddcacde86d3b0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "1837beaf1c4a9efe10e8f8f6dafea3e494708a2512d7b64a396ed69b419b8c7e65f6ba2809c8555b62be2640d0ae297a68136f7a3fe8e226b87467e67c6254a6a2",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 31,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "cfbec3b9d7acc7a9d585d1a5c28fcca119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "1b3587d675b3170ab20fdb14f8c88e42",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 32,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d29cd099d197d1b0da9aca8ec2a3daa5c38a",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "93b0fc1b0313bab41929332682d9d2a5b947fbb5bbdb37c06d897dfc4a309eff5f1b1806a6a3b6f5fedf",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 33,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de89d193d18c75c2b8c6bcd190d198c3a2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "e44c3d06dc5128edd230cd9bfcbd204fde2369fb39d6370cbb0021ce1610003d7d1789e83f3fa9ed5842f1a5afde4609b3b09ef6595b7f8647c26c6ae87f4b776f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 34,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "da9dd096cb93db88dbbed495ceaacf853c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "6a294cfc91bd87da2700085a289a3f10",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 35,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "de81d0a6cea9c7b3cbabce9bda90d29f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "18d49eaf798f4fab75aaadb42f8bcff45b83da5fad7932705cc829897692cedb161268aa04659ef2a1d8",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 36,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "Utf8"
+          ],
+          "password" : "d281da97c8a3d8b7ceb9db80dc8dd4a0c3be",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "ab933595222171a6bf0ef7288f4761d4e6dd29e68e566fde1b3bb6a5ae380b591334e680ed1fc70f181c54f3a6ea17388cf25c7f18555b1f67a6837b3590578161",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 37,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "8423ec7ea4ca4b03",
+          "salt" : "8dfae85c9f2072ae",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "196cb574f499f6e20cd00a4eb0f70d4a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 38,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "2c3c932bf0648bda",
+          "salt" : "15187b0393d8a441",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "28ea911364150649f9c5831e5d3ab76b4c4009416ba68143388d00e294437ade367fd24fbc79a5173544",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 39,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "393d474fd84a259d",
+          "salt" : "775bde4bd6e40ddd",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "38688a90efa49043340f480ba134e4d34f2f444c049864b918fbae5e324c7d11e5c790c4d6c4741bbedccc55f02a40a19edb117339e21fff27fd3b3b56b348403f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 40,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "48997492c3528738",
+          "salt" : "99c55e182238c8e0c385447685e9ba85",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "f3d1fa6c1cb2bc8f8fae275b69746f03",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 41,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "74064c3ebc53d676",
+          "salt" : "42a7aca6a7664f87b405b49d62a074db",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "abb8c155cde2637d33261bd5c1db245ceb1e59e8cf223927697375bbc42273bf4f291d582885f9a5b634",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 42,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "668d4f2c8f86f4f0",
+          "salt" : "f3eb3938f338fe8639813beacd8100a5",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "51960fa5ea92577833bd8ef87911c7e0ace4a94fd4f76afb6ec145455257593953389c7cbe497eae1d787f95b8472b0c251406492c64c25db19ace47d24d8f5a75",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 43,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "e3feb0f96dec61e9dd451465f88f132119",
+          "salt" : "e9c55717a1259a29",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "2008be3283e9f6049829a4157c1c3088",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 44,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "5c9ca419dc576470269a828e40a3aea5ca",
+          "salt" : "5acc2d76a9f4444c",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "81a8a54d860990b96d5aa9debadc95c6164ac85294a2350b20b893e73ab7421845bc69ede73805bfcff7",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 45,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "bf898453344c6875f0b8b9bc2c50fc58e2",
+          "salt" : "c69c3b58917e0975",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "0452a418242f9539f8849d65466c96c984e021ddfde942a6a7e0b6dd1584dc8a536b1722684021444bf60fd958f391a5ba92fa6a44450d089faec32179f01f04c6",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 46,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "9e9d7c166ad3bec856fe5d15d3aacbc53c",
+          "salt" : "200bba668b2010b1968b82091848937c",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "6320a1e2cc157e09607d4fc9a4ae3269",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 47,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "17812c26bba941f3c2ebdb9bb6904c9f73",
+          "salt" : "36586fdefbe916369412c9f3e6337ddf",
+          "iterationCount" : 4096,
+          "dkLen" : 42,
+          "dk" : "d42a8d13e4901d902c6ebf070fa3b253af9a0783cf5260daca193090337ad8ab783a2e46eb8022996c18",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 48,
+          "comment" : "",
+          "flags" : [
+            "Pseudorandom",
+            "NonUtf8"
+          ],
+          "password" : "6c816697ba2366375bb986c0af0d7d20fe",
+          "salt" : "ab7c17e3b78fe71e373b0ccb0fc3ccda",
+          "iterationCount" : 4096,
+          "dkLen" : 65,
+          "dk" : "634fc75709b5d0e585269c6f31280bdc79c1aaaf72fd5a13e9848c5fc1be7caeff06fbda3fd83ec67aee6286e4411da66f1c9da5414734d5388f9f95c25a13c99a",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 49,
+          "comment" : "empty password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "",
+          "salt" : "1a71e2118c9fbcc9",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "580ad63c3ade95c330d57e32af17fd342457fb0289b5d74c2d01ee109293bbdb",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 50,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "523249584467597a5a4271363970667a4a714e744b7761545a4544494676766b6a6253417167566e456a6b456b454557504e69383653626a6e376b725764394d67",
+          "salt" : "d26b99043c8ba3a4",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "983adc3df73cffc0649a9c9682498c6bacbe91980e809d0cf002200d913b2b73",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 51,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "63727a466d396430795463456a6468545758693877674e516f544e6d486e61686f6956317071613133655471477933497531354b4f52516339494c53646756527a45524e6b4463723565676a62584a78426572536a74726b6b674341616a63356243354434706e66743836663754626663666370595a30767354454d4930524178",
+          "salt" : "9266da5b8c102b27",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "9a3a9c839c05c455f1e83959f486b23b15f6e91bdf71b3da11bb0dd71ec98d49",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 52,
+          "comment" : "long password",
+          "flags" : [
+            "Pseudorandom",
+            "Printable"
+          ],
+          "password" : "32647a56696e6f45774464656c656257797058314d6f4f685975585a463172514b7a32535a6c307578576377796f3561416e6f42524e7a5044763072513662693742345a34324f50695258534c6859684441643262746f647633744d54424430744b46316e4b655942656565547a70413145434150713942687a4a4c555a67737636754e4b664450333558414d684a486c736a6f5a796b677130624d506265556941796d6f324371586b64524752633876544176684e5a5838536f564d33704e74594a4a7258766975337547583233736a353847723061614a4b45763765796c373248636e6167713474766e533737626d6376676c79536d347370707a65673869",
+          "salt" : "6a06903b78dae6de",
+          "iterationCount" : 4096,
+          "dkLen" : 32,
+          "dk" : "c27d89fdfb870fe02f4e3843025d33e91fa2eff7f8a18eefe7113818d1765126",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 53,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ff",
+          "salt" : "32140a66b88e1683",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "73eb7888fbad86cdb299455454d26429",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 54,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "ffffffffffffffff",
+          "salt" : "8a359634423ed028",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "f0bdbf5507d74ab49ee220500107d69f",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 55,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "c0c0c0c0c0c0c0c0",
+          "salt" : "d6f596f170ed2414",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "51eaac2999bab0ba22134d21f95ea277",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 56,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "eeeeeeeeeeeeeeee",
+          "salt" : "6b2269425e288d03",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "f4e6be1916b7d1e21e8c4ea77c75b54e",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 57,
+          "comment" : "special case password",
+          "flags" : [
+            "NonUtf8"
+          ],
+          "password" : "f0f0f0f0f0f0f0f0",
+          "salt" : "ebf0b04633711248",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "51baf8f1ecab753e30fd1ff995c29355",
+          "result" : "valid"
+        },
+        {
+          "tcId" : 58,
+          "comment" : "special case password",
+          "flags" : [
+            "Ascii"
+          ],
+          "password" : "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+          "salt" : "9de9b71eeb9d9a34",
+          "iterationCount" : 4096,
+          "dkLen" : 16,
+          "dk" : "2900d1f4870e09094070d986784dbcc8",
+          "result" : "valid"
+        }
+      ]
+    }
+  ]
+}
diff --git a/testdata/srp_init_ok_test.json b/testdata/srp_init_ok_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/srp_init_ok_test.json
@@ -0,0 +1,7 @@
+{
+  "iteration": 20433,
+  "salt": "0samK84bcBmkVsswOpZbZg==",
+  "protocol": "s2k",
+  "b": "STVHcWTN9YOYn4IgtIJ6UPdPbvzvL+zza/l+6yUHUtdEyxwzpB78y8wqZ8QWSbVqjBcpl32iEA4T3nYp0LWZ5hD3r3yIJFloXvX0kpBJkr+Nh8EfHuW1V50A8riH6VWyuJ8m3JmOO7/xkNgP7je8GMpt/5f/7qE3AOj73e3JR0fzQ7IopdU0tlyVX0tD7T6wCyHS52GJWDdq1I2bgzurIK2/ZjR/Hwzd/67oFQPtKQgjrSRaKo5MJEfDP7C9wOlXsZqbb7igX6PeZRWrfl+iQFaA/FVeWSngB07ja3wOryY9GsYO06ELGOaQ+MpsT7mouqrGTfOJ0OMh9EgrkJEM6w==",
+  "c": "e-1be-8746c235-b41c-11ef-bd17-c780acb4fe15:PRN"
+}
diff --git a/testdata/trust_data_test.json b/testdata/trust_data_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/trust_data_test.json
@@ -0,0 +1,13 @@
+{
+  "securityCode": {
+    "length": 6,
+    "tooManyCodesSent": false,
+    "tooManyCodesValidated": false,
+    "securityCodeLocked": false,
+    "securityCodeCooldown": false
+  },
+  "trustedPhoneNumbers": [
+    { "id": 1, "numberWithDialCode": "+81 •• •••• •34", "pushMode": "sms" }
+  ],
+  "noTrustedDevices": false
+}
diff --git a/testdata/trusted_devices_test.json b/testdata/trusted_devices_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/trusted_devices_test.json
@@ -0,0 +1,10 @@
+{
+  "devices": [
+    {
+      "deviceType": "SMS",
+      "areaCode": "",
+      "phoneNumber": "*******58",
+      "deviceId": "1"
+    }
+  ]
+}
diff --git a/testdata/verification_code_ok_test.json b/testdata/verification_code_ok_test.json
new file mode 100644
--- /dev/null
+++ b/testdata/verification_code_ok_test.json
@@ -0,0 +1,3 @@
+{
+  "success": true
+}
