packages feed

jose-jwt-botan-0.12.0: benchmarks/loadtest-jwt/Main.hs

{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}

-- Tests the performance of JWT verification, see https://github.com/PostgREST/postgrest/issues/5379
module Main where

import Control.DeepSeq (force)
import Control.Exception (evaluate)
import Control.Lens (view)
import Control.Monad (replicateM_)
import Data.Aeson ((.=))
import Data.ByteString (ByteString)
import Data.Functor.Identity (Identity (..))
import GHC.Clock (getMonotonicTimeNSec)
import Text.Printf (printf)
import Prelude

import Crypto.JOSE qualified as JOSE
import Data.Aeson qualified as JSON
import Data.ByteString.Lazy qualified as LBS
import Jose.Jwk qualified as JWT
import Jose.Jwt qualified as JWT
import Options.Applicative qualified as O

main :: IO ()
main = do
  algorithm <- readCLI

  -- init
  (jwsAlgorithm, publicKey, privateKey) <- generateKeyPair algorithm
  tokens' <- mapM (generateToken jwsAlgorithm privateKey) [1 .. uniqueTokens]
  tokens <- evaluate $ force tokens' -- force the evaluation before the verification phase, otherwise due to laziness it will be evaluated later

  -- bench
  startNSecs <- getMonotonicTimeNSec
  replicateM_ times $ mapM_ (verify $ JWT.JwkSet [publicKey]) tokens
  endNSecs <- getMonotonicTimeNSec

  -- report
  let
    totalVerifications = uniqueTokens * times
    benchSeconds = fromIntegral (endNSecs - startNSecs) / 1e9 :: Double
    tokensPerSecond = fromIntegral totalVerifications / benchSeconds
   in
    printf "- %s: %.0f verify/s (%d verifications, %.3fs)\n" (show algorithm) tokensPerSecond totalVerifications benchSeconds
  where
    -- These numbers were hand tuned, if we generate too many unique tokens the signing phase takes too long.
    uniqueTokens = 1000
    -- So we verify the uniqueTokens a number of times. Ed25519 is really fast, if we just run times = 2 then `postgrest-loadtest-jwt` fails because the `monitor_pid.py`
    -- doesn't have a chance to capture the process CPU/MEM. So we choose 10 for good balance.
    times = 10

-- | PostgREST supports more algorithms (see https://github.com/PostgREST/postgrest/issues/5379) but we only load test these variants
data CliAlgorithm = RS256 | ES256 | Ed25519 deriving (Show)

readCLI :: IO CliAlgorithm
readCLI = O.execParser $ O.info (O.helper <*> algo) (O.progDesc "JWT Verification Loadtest")
  where
    algo = O.option (O.eitherReader readAlgo) $ O.long "algorithm"
    readAlgo "RS256" = Right RS256
    readAlgo "ES256" = Right ES256
    readAlgo "Ed25519" = Right Ed25519
    readAlgo _ = Left "Unknown algorithm"

verify :: JWT.JwkSet -> ByteString -> IO ()
verify jwks token = do
  result <- JWT.decode (JWT.keys jwks) Nothing token
  case result of
    Left err ->
      ioError $ userError $ show err
    Right _ ->
      pure () -- just discard the value

generateKeyPair :: CliAlgorithm -> IO (JOSE.Alg, JWT.Jwk, JOSE.JWK)
generateKeyPair algorithm = do
  let (jwsAlgorithm, params) = case algorithm of
        RS256 ->
          -- The key size is taken from the example on hs-jose
          -- https://github.com/frasertweedale/hs-jose/blob/ffd6a66c2e2844ec5e506806963f4e46accf1ae9/src/Crypto/JOSE/JWK.hs#L34
          let keySize = 4096 `div` 8
          in  (JOSE.RS256, JOSE.RSAGenParam keySize)
        ES256 -> (JOSE.ES256, JOSE.ECGenParam JOSE.P_256)
        Ed25519 -> (JOSE.EdDSA, JOSE.OKPGenParam JOSE.Ed25519)
  privateKey <- JOSE.genJWK params
  case JSON.fromJSON $ JSON.toJSON $ view JOSE.asPublicKey privateKey of
    JSON.Error err -> ioError $ userError err
    JSON.Success publicKey -> pure (jwsAlgorithm, publicKey, privateKey)

generateToken :: JOSE.Alg -> JOSE.JWK -> Int -> IO ByteString
generateToken alg privateKey tokenId = do
  result <- JOSE.runJOSE $ JOSE.signJWS claims $ Identity (header, privateKey)
  case result :: Either JOSE.Error (JOSE.CompactJWS JOSE.JWSHeader) of
    Left err -> ioError $ userError $ show err
    Right jwt -> pure $ LBS.toStrict $ JOSE.encodeCompact jwt
  where
    header = JOSE.newJWSHeaderProtected alg
    -- This is a representative JWT, similar to one generated on nix/tools/generate_targets.py
    claims = JSON.encode $ JSON.object ["role" .= ("postgrest_test_author" :: String), "sub" .= ("user_" <> show tokenId)]