paseto-0.1.1.1: src/Crypto/Paseto/Protocol/V3.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
-- | Implementation of
-- [PASETO version 3](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version3.md)
-- (modern NIST cryptography).
module Crypto.Paseto.Protocol.V3
( -- * Local purpose
v3LocalTokenHeader
, EncryptionError (..)
, renderEncryptionError
, encrypt
, encryptPure
, DecryptionError (..)
, renderDecryptionError
, decrypt
-- * Public purpose
, v3PublicTokenHeader
, SigningError (..)
, renderSigningError
, sign
, signPure
, VerificationError (..)
, renderVerificationError
, verify
) where
import Control.Monad ( unless, when )
import Control.Monad.Except ( ExceptT )
import Control.Monad.IO.Class ( liftIO )
import Control.Monad.Trans.Except.Extra ( hoistEither )
import qualified Crypto.Cipher.AES as Crypto
import qualified Crypto.Cipher.Types as Crypto
import qualified Crypto.Error as Crypto
import qualified Crypto.Hash as Crypto
import qualified Crypto.KDF.HKDF as Crypto
import qualified Crypto.MAC.HMAC as Crypto
import Crypto.Paseto.Keys
( SigningKey (..)
, SymmetricKey (..)
, VerificationKey (..)
, fromSigningKey
, verificationKeyToBytes
)
import Crypto.Paseto.Keys.V3
( PrivateKeyP384 (..)
, PublicKeyP384 (..)
, generateScalarP384
, isScalarValidP384
)
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.ECC.ECDSA as Crypto
import qualified Crypto.Random as Crypto
import qualified Data.Aeson as Aeson
import Data.Bifunctor ( first )
import Data.Binary.Put ( runPut )
import Data.Binary.Put.Integer ( putIntegerbe )
import Data.Bits ( shiftL, (.|.) )
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
maybeToEither :: a -> Maybe b -> Either a b
maybeToEither _ (Just b) = Right b
maybeToEither a Nothing = Left a
------------------------------------------------------------------------------
-- Local purpose
------------------------------------------------------------------------------
v3LocalTokenHeader :: ByteString
v3LocalTokenHeader = "v3.local."
encryptionKeyHkdfInfoPrefix :: ByteString
encryptionKeyHkdfInfoPrefix = "paseto-encryption-key"
authenticationKeyHkdfInfoPrefix :: ByteString
authenticationKeyHkdfInfoPrefix = "paseto-auth-key-for-aead"
mkAes256Cipher :: ByteString -> Either Crypto.CryptoError Crypto.AES256
mkAes256Cipher ek = Crypto.eitherCryptoError (Crypto.cipherInit ek)
-- | PASETO version 3 encryption error.
data EncryptionError
= -- | 'Crypto.CryptoError' that occurred during encryption.
EncryptionCryptoError !Crypto.CryptoError
| -- | Initialization vector is of an invalid size.
EncryptionInvalidInitializationVectorSizeError
-- | Expected size.
!Int
-- | Actual size.
!Int
deriving stock (Show, Eq)
-- | Render an 'EncryptionError' as 'Text'.
renderEncryptionError :: EncryptionError -> Text
renderEncryptionError err =
case err of
EncryptionCryptoError e ->
"Encountered a cryptographic error: " <> T.pack (show e)
EncryptionInvalidInitializationVectorSizeError expected actual ->
"Initialization vector length is expected to be "
<> T.pack (show expected)
<> ", but it was "
<> T.pack (show actual)
<> "."
-- | 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 V3
-- ^ 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.
-> Either EncryptionError (Token V3 Local)
encryptPure n (SymmetricKeyV3 k) cs f i = do
let h :: ByteString
h = v3LocalTokenHeader
m :: ByteString
m = BS.toStrict (Aeson.encode cs)
prk :: Crypto.PRK Crypto.SHA384
prk = Crypto.extract BS.empty k
ek :: ByteString
n2 :: ByteString
(ek, n2) = BS.splitAt 32 $ Crypto.expand prk (encryptionKeyHkdfInfoPrefix <> n) 48
ak :: ByteString
ak = Crypto.expand prk (authenticationKeyHkdfInfoPrefix <> n) 48
aes256 <- first EncryptionCryptoError (mkAes256Cipher ek)
iv <-
maybeToEither
(EncryptionInvalidInitializationVectorSizeError (Crypto.blockSize aes256) (BS.length n2))
(Crypto.makeIV n2)
let c :: ByteString
c = Crypto.ctrCombine aes256 iv m
preAuth :: ByteString
preAuth = PAE.encode [h, n, c, maybe BS.empty unFooter f, maybe BS.empty unImplicitAssertion i]
t :: Crypto.HMAC Crypto.SHA384
t = Crypto.hmac ak preAuth
payload :: Payload
payload = Payload (n <> c <> BA.convert t)
pure $ TokenV3Local payload f
-- | [PASETO version 3 encryption](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version3.md#encrypt).
--
-- This is an authenticated encryption with associated data (AEAD)
-- algorithm which combines the @AES-256-CTR@ block cipher with the
-- @HMAC-SHA384@ 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 V3
-- ^ 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.
-> ExceptT EncryptionError IO (Token V3 Local)
encrypt k cs f i = do
n <- liftIO (Crypto.getRandomBytes 32 :: IO ByteString)
hoistEither (encryptPure n k cs f i)
-- | PASETO version 3 decryption error.
data DecryptionError
= -- | Invalid token footer.
DecryptionInvalidFooterError
-- | Expected footer.
!(Maybe Footer)
-- | Actual footer.
!(Maybe Footer)
| -- | Invalid @HKDF-HMAC-SHA384@ nonce size.
DecryptionInvalidHkdfNonceSizeError !Int
| -- | Invalid @HMAC-SHA384@ message authentication code size.
DecryptionInvalidHmacSizeError !Int
| -- | Invalid @HMAC-SHA384@ message authentication code.
DecryptionInvalidHmacError
-- | Expected HMAC.
!ByteString
-- | Actual HMAC.
!ByteString
| -- | 'Crypto.CryptoError' that occurred during decryption.
DecryptionCryptoError !Crypto.CryptoError
| -- | Initialization vector is of an invalid size.
DecryptionInvalidInitializationVectorSizeError
-- | Expected size.
!Int
-- | Actual size.
!Int
| -- | 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."
DecryptionInvalidHkdfNonceSizeError actual ->
"Expected nonce with a size of 32, but it was "
<> T.pack (show actual)
<> "."
DecryptionInvalidHmacSizeError actual ->
"Expected HMAC with a size of 48, but it was "
<> T.pack (show actual)
<> "."
DecryptionInvalidHmacError expected actual ->
"Expected HMAC value of "
<> TE.decodeUtf8 (B16.encode expected)
<> ", but encountered "
<> TE.decodeUtf8 (B16.encode actual)
<> "."
DecryptionCryptoError e ->
"Encountered a cryptographic error: " <> T.pack (show e)
DecryptionInvalidInitializationVectorSizeError expected actual ->
"Initialization vector length is expected to be "
<> T.pack (show expected)
<> ", but it was "
<> T.pack (show actual)
<> "."
DecryptionClaimsDeserializationError e ->
"Error deserializing claims from JSON: " <> T.pack (show e)
-- | [PASETO version 3 decryption](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version3.md#decrypt).
decrypt
:: SymmetricKey V3
-- ^ Symmetric key.
-> Token V3 Local
-- ^ Token to decrypt.
-> Maybe Footer
-- ^ Optional footer to authenticate.
-> Maybe ImplicitAssertion
-- ^ Optional implicit assertion to authenticate.
-> Either DecryptionError Claims
decrypt (SymmetricKeyV3 k) (TokenV3Local (Payload m) actualF) expectedF i = do
let h :: ByteString
h = v3LocalTokenHeader
-- 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 48 m
mbT :: Maybe (Crypto.HMAC Crypto.SHA384)
mbT = Crypto.HMAC <$> Crypto.digestFromByteString tBs
c :: ByteString
c = BS.dropEnd 48 (BS.drop 32 m)
when (nLen /= 32) (Left $ DecryptionInvalidHkdfNonceSizeError nLen)
t <-
case mbT of
Nothing -> Left (DecryptionInvalidHmacSizeError $ BS.length tBs)
Just x -> Right x
let prk :: Crypto.PRK Crypto.SHA384
prk = Crypto.extract BS.empty k
ek :: ByteString
n2 :: ByteString
(ek, n2) = BS.splitAt 32 $ Crypto.expand prk (encryptionKeyHkdfInfoPrefix <> n) 48
ak :: ByteString
ak = Crypto.expand prk (authenticationKeyHkdfInfoPrefix <> n) 48
preAuth :: ByteString
preAuth = PAE.encode [h, n, c, maybe BS.empty unFooter actualF, maybe BS.empty unImplicitAssertion i]
t2 :: Crypto.HMAC Crypto.SHA384
t2 = Crypto.hmac ak preAuth
-- The 'Crypto.HMAC' 'Eq' instance performs a constant-time equality check.
when (t2 /= t) (Left $ DecryptionInvalidHmacError (BA.convert t2) (BA.convert t))
aes256 <- first DecryptionCryptoError (mkAes256Cipher ek)
iv <-
maybeToEither
(DecryptionInvalidInitializationVectorSizeError (Crypto.blockSize aes256) (BS.length n2))
(Crypto.makeIV n2)
let decrypted :: ByteString
decrypted = Crypto.ctrCombine aes256 iv c
-- Deserialize the raw decrypted bytes as a JSON object of claims.
first DecryptionClaimsDeserializationError (Aeson.eitherDecodeStrict decrypted)
------------------------------------------------------------------------------
-- Public purpose
------------------------------------------------------------------------------
v3PublicTokenHeader :: ByteString
v3PublicTokenHeader = "v3.public."
-- | PASETO version 3 cryptographic signing error.
data SigningError
= -- | Random number, @k@, is zero.
SigningKIsZeroError
deriving (Show, Eq)
-- | Render a 'SigningError' as 'Text'.
renderSigningError :: SigningError -> Text
renderSigningError err =
case err of
SigningKIsZeroError -> "Parameter k is 0."
-- | Pure variant of 'sign'.
--
-- For typical usage, please use 'sign'.
signPure
:: Integer
-- ^ Explicit @k@ scalar.
-> SigningKey V3
-- ^ 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.
-> Either SigningError (Token V3 Public)
signPure k signingKey@(SigningKeyV3 (PrivateKeyP384 sk)) cs f i = do
let h :: ByteString
h = v3PublicTokenHeader
m :: ByteString
m = BS.toStrict (Aeson.encode cs)
vk :: VerificationKey V3
vk = fromSigningKey signingKey
m2 :: ByteString
m2 = PAE.encode [verificationKeyToBytes vk, h, m, maybe BS.empty unFooter f, maybe BS.empty unImplicitAssertion i]
sig <-
maybeToEither
SigningKIsZeroError
(Crypto.signWith k sk Crypto.SHA384 m2)
let r :: Integer
r = Crypto.sign_r sig
s :: Integer
s = Crypto.sign_s sig
sigBs :: ByteString
sigBs =
padTo 48 (BS.toStrict $ runPut (putIntegerbe r))
<> padTo 48 (BS.toStrict $ runPut (putIntegerbe s))
payload :: Payload
payload = Payload (m <> sigBs)
Right $ TokenV3Public payload f
where
padTo :: Int -> ByteString -> ByteString
padTo n bs
| n <= 0 = bs
| BS.length bs >= n = bs
| otherwise = BS.replicate (n - BS.length bs) 0 <> bs
-- | [PASETO version 3 cryptographic signing](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version3.md#sign).
--
-- This implementation produces a token which is signed using @ECDSA@ over
-- @P-384@ and @SHA-384@.
--
-- Note that this function essentially just calls 'signPure' with a
-- randomly-generated scalar multiple, @k@.
sign
:: SigningKey V3
-- ^ 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.
-> ExceptT SigningError IO (Token V3 Public)
sign sk cs f i = do
k <- liftIO generateScalarP384
hoistEither (signPure k sk cs f i)
-- | PASETO version 3 signature verification error.
data VerificationError
= -- | Invalid token footer.
VerificationInvalidFooterError
-- | Expected footer.
!(Maybe Footer)
-- | Actual footer.
!(Maybe Footer)
| -- | Signature size is invalid.
VerificationInvalidSignatureSizeError
| -- | 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."
VerificationInvalidSignatureSizeError -> "Signature size is invalid."
VerificationInvalidSignatureError -> "Signature is invalid."
VerificationClaimsDeserializationError e ->
"Error deserializing claims from JSON: " <> T.pack (show e)
-- | [PASETO version 3 cryptographic signature verification](https://github.com/paseto-standard/paseto-spec/blob/af79f25908227555404e7462ccdd8ce106049469/docs/01-Protocol-Versions/Version3.md#verify).
verify
:: VerificationKey V3
-- ^ Verification key.
-> Token V3 Public
-- ^ Token to verify.
-> Maybe Footer
-- ^ Optional footer to authenticate.
-> Maybe ImplicitAssertion
-- ^ Optional implicit assertion to authenticate.
-> Either VerificationError Claims
verify verKey@(VerificationKeyV3 (PublicKeyP384 vk)) (TokenV3Public (Payload sm) actualF) expectedF i = do
let h :: ByteString
h = v3PublicTokenHeader
-- Check that the actual footer matches the provided expected footer.
when (expectedF /= actualF) (Left $ VerificationInvalidFooterError expectedF actualF)
let sigBs :: ByteString
sigBs = BS.takeEnd 96 sm
rBs :: ByteString
sBs :: ByteString
(rBs, sBs) = BS.splitAt 48 sigBs
r :: Integer
r = bsToInteger rBs
s :: Integer
s = bsToInteger sBs
sig <- sigFromIntegers (r, s)
let m :: ByteString
m = BS.dropEnd 96 sm
m2 :: ByteString
m2 = PAE.encode [verificationKeyToBytes verKey, h, m, maybe BS.empty unFooter actualF, maybe BS.empty unImplicitAssertion i]
unless
(Crypto.verify Crypto.SHA384 vk sig m2)
(Left VerificationInvalidSignatureError)
first VerificationClaimsDeserializationError (Aeson.eitherDecodeStrict m)
where
-- Decode a big endian 'Integer' from a 'ByteString'.
--
-- Ripped from @haskoin-core-1.1.0@.
bsToInteger :: ByteString -> Integer
bsToInteger = BS.foldr f 0 . BS.reverse
where
f w n = toInteger w .|. shiftL n 8
mkValidScalar :: Integer -> Either VerificationError Integer
mkValidScalar s
| isScalarValidP384 s = Right s
| otherwise = Left VerificationInvalidSignatureSizeError
sigFromIntegers :: (Integer, Integer) -> Either VerificationError Crypto.Signature
sigFromIntegers (r, s) =
Crypto.Signature <$> mkValidScalar r <*> mkValidScalar s