packages feed

paseto-0.1.0.0: src/Crypto/Paseto/Protocol/V4.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}

-- | Implementation of
-- [PASETO version 4](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version4.md)
-- (modern [Sodium](https://doc.libsodium.org/) cryptography).
--
-- Note that we're not actually using @libsodium@ itself in this module but,
-- instead, the equivalent algorithm implementations that are available in
-- @crypton@.
module Crypto.Paseto.Protocol.V4
  ( -- * Local purpose
    v4LocalTokenHeader
  , encrypt
  , encryptPure
  , DecryptionError (..)
  , renderDecryptionError
  , decrypt

    -- * Public purpose
  , v4PublicTokenHeader
  , sign
  , VerificationError (..)
  , renderVerificationError
  , verify
  ) where

import Control.Monad ( unless, when )
import qualified Crypto.Cipher.ChaCha as Crypto
import qualified Crypto.Error as Crypto
import qualified Crypto.Hash as Crypto
import qualified Crypto.MAC.KeyedBlake2 as Crypto
import Crypto.Paseto.Keys
  ( SigningKey (..), SymmetricKey (..), VerificationKey (..) )
import Crypto.Paseto.Mode ( Purpose (..), Version (..) )
import qualified Crypto.Paseto.PreAuthenticationEncoding as PAE
import Crypto.Paseto.Token
  ( Footer (..), ImplicitAssertion (..), Payload (..), Token (..) )
import Crypto.Paseto.Token.Claims ( Claims )
import qualified Crypto.PubKey.Ed25519 as Crypto
import qualified Crypto.Random as Crypto
import qualified Data.Aeson as Aeson
import Data.Bifunctor ( first )
import qualified Data.ByteArray as BA
import Data.ByteString ( ByteString )
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import Data.Text ( Text )
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Prelude

------------------------------------------------------------------------------
-- Local purpose
------------------------------------------------------------------------------

v4LocalTokenHeader :: ByteString
v4LocalTokenHeader = "v4.local."

encryptionKeyHkdfInfoPrefix :: ByteString
encryptionKeyHkdfInfoPrefix = "paseto-encryption-key"

authenticationKeyHkdfInfoPrefix :: ByteString
authenticationKeyHkdfInfoPrefix = "paseto-auth-key-for-aead"

-- | Pure variant of 'encrypt'.
--
-- For typical usage, please use 'encrypt'.
encryptPure
  :: ByteString
  -- ^ Random 32-byte nonce.
  --
  -- It is recommended to generate this from the operating system's CSPRNG.
  -> SymmetricKey V4
  -- ^ Symmetric key.
  -> Claims
  -- ^ Claims to be encrypted.
  -> Maybe Footer
  -- ^ Optional footer to authenticate and encode within the resulting token.
  -> Maybe ImplicitAssertion
  -- ^ Optional implicit assertion to authenticate.
  -> Token V4 Local
encryptPure n (SymmetricKeyV4 k) cs f i =
  let h :: ByteString
      h = v4LocalTokenHeader

      m :: ByteString
      m = BS.toStrict (Aeson.encode cs)

      tmp :: Crypto.KeyedBlake2 (Crypto.Blake2b 448)
      tmp = Crypto.keyedBlake2 k (encryptionKeyHkdfInfoPrefix <> n)

      ek :: ByteString
      n2 :: ByteString
      (ek, n2) = BS.splitAt 32 $ BA.convert tmp

      ak :: Crypto.KeyedBlake2 (Crypto.Blake2b 256)
      ak = Crypto.keyedBlake2 k (authenticationKeyHkdfInfoPrefix <> n)

      xChaCha20St :: Crypto.State
      xChaCha20St = Crypto.initializeX 20 ek n2

      c :: ByteString
      (c, _) = Crypto.combine xChaCha20St m

      preAuth :: ByteString
      preAuth = PAE.encode [h, n, c, maybe BS.empty unFooter f, maybe BS.empty unImplicitAssertion i]

      t :: Crypto.KeyedBlake2 (Crypto.Blake2b 256)
      t = Crypto.keyedBlake2 (BA.convert ak :: ByteString) preAuth

      payload :: Payload
      payload = Payload (n <> c <> BA.convert t)

  in TokenV4Local payload f

-- | [PASETO version 4 encryption](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version4.md#encrypt).
--
-- This is an authenticated encryption with associated data (AEAD)
-- algorithm which combines the @XChaCha20@ stream cipher with the @Blake2b@
-- message authentication code.
--
-- Note that this function essentially just calls 'encryptPure' with a random
-- 32-byte nonce generated from the operating system's CSPRNG.
encrypt
  :: SymmetricKey V4
  -- ^ Symmetric key.
  -> Claims
  -- ^ Claims to be encrypted.
  -> Maybe Footer
  -- ^ Optional footer to authenticate and encode within the resulting token.
  -> Maybe ImplicitAssertion
  -- ^ Optional implicit assertion to authenticate.
  -> IO (Token V4 Local)
encrypt k cs f i = do
  n <- Crypto.getRandomBytes 32
  pure (encryptPure n k cs f i)

-- | PASETO version 4 decryption error.
data DecryptionError
  = -- | Invalid token footer.
    DecryptionInvalidFooterError
      -- | Expected footer.
      !(Maybe Footer)
      -- | Actual footer.
      !(Maybe Footer)
  | -- | Invalid nonce size.
    DecryptionInvalidNonceSizeError !Int
  | -- | Invalid @Blake2b@ message authentication code size.
    DecryptionInvalidMacSizeError !Int
  | -- | Invalid @Blake2b@ message authenticartion code.
    DecryptionInvalidMacError
      -- | Expected MAC.
      !ByteString
      -- | Actual MAC.
      !ByteString
  | -- | Error deserializing a decrypted collection of claims as JSON.
    DecryptionClaimsDeserializationError !String
  deriving stock (Show, Eq)

-- | Render a 'DecryptionError' as 'Text'.
renderDecryptionError :: DecryptionError -> Text
renderDecryptionError err =
  case err of
    DecryptionInvalidFooterError _ _ ->
      -- Since a footer could potentially be very long or some kind of
      -- illegible structured data, we're not going to attempt to render those
      -- values here.
      "Token has an invalid footer."
    DecryptionInvalidNonceSizeError actual ->
      "Expected nonce with a size of 32, but it was "
        <> T.pack (show actual)
        <> "."
    DecryptionInvalidMacSizeError actual ->
      "Expected MAC with a size of 32, but it was "
        <> T.pack (show actual)
        <> "."
    DecryptionInvalidMacError expected actual ->
      "Expected MAC value of "
        <> TE.decodeUtf8 (B16.encode expected)
        <> ", but encountered "
        <> TE.decodeUtf8 (B16.encode actual)
        <> "."
    DecryptionClaimsDeserializationError e ->
      "Error deserializing claims from JSON: " <> T.pack (show e)

-- | [PASETO version 4 decryption](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version4.md#decrypt).
decrypt
  :: SymmetricKey V4
  -- ^ Symmetric key.
  -> Token V4 Local
  -- ^ Token to decrypt.
  -> Maybe Footer
  -- ^ Optional footer to authenticate.
  -> Maybe ImplicitAssertion
  -- ^ Optional implicit assertion to authenticate.
  -> Either DecryptionError Claims
decrypt (SymmetricKeyV4 k) (TokenV4Local (Payload m) actualF) expectedF i = do
  let h :: ByteString
      h = v4LocalTokenHeader

  -- Check that the actual footer matches the provided expected footer.
  when (expectedF /= actualF) (Left $ DecryptionInvalidFooterError expectedF actualF)

  let n :: ByteString
      n = BS.take 32 m

      nLen :: Int
      nLen = BS.length n

      tBs :: ByteString
      tBs = BS.takeEnd 32 m

      mbT :: Maybe (Crypto.KeyedBlake2 (Crypto.Blake2b 256))
      mbT = Crypto.KeyedBlake2 <$> Crypto.digestFromByteString tBs

      c :: ByteString
      c = BS.dropEnd 32 (BS.drop 32 m)

  when (nLen /= 32) (Left $ DecryptionInvalidNonceSizeError nLen)

  t <-
    case mbT of
      Nothing -> Left (DecryptionInvalidMacSizeError $ BS.length tBs)
      Just x -> Right x

  let tmp :: Crypto.KeyedBlake2 (Crypto.Blake2b 448)
      tmp = Crypto.keyedBlake2 k (encryptionKeyHkdfInfoPrefix <> n)

      ek :: ByteString
      n2 :: ByteString
      (ek, n2) = BS.splitAt 32 $ BA.convert tmp

      ak :: Crypto.KeyedBlake2 (Crypto.Blake2b 256)
      ak = Crypto.keyedBlake2 k (authenticationKeyHkdfInfoPrefix <> n)

      preAuth :: ByteString
      preAuth = PAE.encode [h, n, c, maybe BS.empty unFooter actualF, maybe BS.empty unImplicitAssertion i]

      t2 :: Crypto.KeyedBlake2 (Crypto.Blake2b 256)
      t2 = Crypto.keyedBlake2 (BA.convert ak :: ByteString) preAuth

  -- The 'Crypto.KeyedBlake2' 'Eq' instance performs a constant-time equality check.
  when (t2 /= t) (Left $ DecryptionInvalidMacError (BA.convert t2) (BA.convert t))

  let xChaCha20St :: Crypto.State
      xChaCha20St = Crypto.initializeX 20 ek n2

      decrypted :: ByteString
      (decrypted, _) = Crypto.combine xChaCha20St c

  -- Deserialize the raw decrypted bytes as a JSON object of claims.
  first DecryptionClaimsDeserializationError (Aeson.eitherDecodeStrict decrypted)

------------------------------------------------------------------------------
-- Public purpose
------------------------------------------------------------------------------

v4PublicTokenHeader :: ByteString
v4PublicTokenHeader = "v4.public."

-- | [PASETO version 4 cryptographic signing](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version4.md#sign).
--
-- This implementation produces a token which is signed using @Ed25519@.
sign
  :: SigningKey V4
  -- ^ Signing key.
  -> Claims
  -- ^ Claims to be signed.
  -> Maybe Footer
  -- ^ Optional footer to authenticate and encode within the resulting token.
  -> Maybe ImplicitAssertion
  -- ^ Optional implicit assertion to authenticate.
  -> Token V4 Public
sign (SigningKeyV4 sk) cs f i =
  let h :: ByteString
      h = v4PublicTokenHeader

      m :: ByteString
      m = BS.toStrict (Aeson.encode cs)

      m2 :: ByteString
      m2 = PAE.encode [h, m, maybe BS.empty unFooter f, maybe BS.empty unImplicitAssertion i]

      sig :: Crypto.Signature
      sig = Crypto.sign sk (Crypto.toPublic sk) m2

      payload :: Payload
      payload = Payload (m <> BA.convert sig)

  in TokenV4Public payload f

-- | PASETO version 4 signature verification error.
data VerificationError
  = -- | Invalid token footer.
    VerificationInvalidFooterError
      -- | Expected footer.
      !(Maybe Footer)
      -- | Actual footer.
      !(Maybe Footer)
  | -- | 'Crypto.CryptoError' that occurred during verification.
    VerificationCryptoError !Crypto.CryptoError
  | -- | Signature verification failed.
    VerificationInvalidSignatureError
  | -- | Error deserializing a verified collection of claims as JSON.
    VerificationClaimsDeserializationError !String
  deriving (Show, Eq)

-- | Render a 'VerificationError' as 'Text'.
renderVerificationError :: VerificationError -> Text
renderVerificationError err =
  case err of
    VerificationInvalidFooterError _ _ ->
      -- Since a footer could potentially be very long or some kind of
      -- illegible structured data, we're not going to attempt to render those
      -- values here.
      "Token has an invalid footer."
    VerificationCryptoError e ->
      "Encountered a cryptographic error: " <> T.pack (show e)
    VerificationInvalidSignatureError -> "Signature is invalid."
    VerificationClaimsDeserializationError e ->
      "Error deserializing claims from JSON: " <> T.pack (show e)

-- | [PASETO version 4 cryptographic signature verification](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version4.md#verify).
verify
  :: VerificationKey V4
  -- ^ Verification key.
  -> Token V4 Public
  -- ^ Token to verify.
  -> Maybe Footer
  -- ^ Optional footer to authenticate.
  -> Maybe ImplicitAssertion
  -- ^ Optional implicit assertion to authenticate.
  -> Either VerificationError Claims
verify (VerificationKeyV4 vk) (TokenV4Public (Payload sm) actualF) expectedF i = do
  let h :: ByteString
      h = v4PublicTokenHeader

  -- Check that the actual footer matches the provided expected footer.
  when (expectedF /= actualF) (Left $ VerificationInvalidFooterError expectedF actualF)

  s <-
    first VerificationCryptoError
      . Crypto.eitherCryptoError
      $ Crypto.signature (BS.takeEnd 64 sm)

  let m :: ByteString
      m = BS.dropEnd 64 sm

      m2 :: ByteString
      m2 = PAE.encode [h, m, maybe BS.empty unFooter actualF, maybe BS.empty unImplicitAssertion i]

  unless
    (Crypto.verify vk m2 s)
    (Left VerificationInvalidSignatureError)

  first VerificationClaimsDeserializationError (Aeson.eitherDecodeStrict m)