packages feed

rails-session-0.1.4.0: src/Web/Rails7/Session.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE LambdaCase #-}

module Web.Rails7.Session (
  -- * Decoding
    decode
  , decodeEither
  , DecodingError(..)
  -- * Decrypting
  , decrypt
  ) where

import Control.Applicative ((<$>))
import Control.Monad
import Crypto.PBKDF.ByteString (sha1PBKDF2, sha256PBKDF2)
import Data.Aeson qualified as JSON
import Data.Bifunctor
import Data.ByteArray qualified as BA
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Base64 qualified as B64
import Data.ByteString.Char8 qualified as C8
import Data.ByteString.Lazy qualified as BL
import Data.Either (Either(..), either)
import Data.Function.Compat ((&))
import Data.Maybe (Maybe(..), fromMaybe)
import Data.Monoid ((<>))
import Data.Ruby.Marshal (RubyObject(..), RubyStringEncoding(..))
import Data.String.Conv (toS)
import Data.Vector qualified as Vec
import Network.HTTP.Types (urlDecode)
import Prelude (Bool(..), Eq, Int, Ord, Show, String, ($!), (.) , (==), const, error, fst, show, snd)
import Prelude hiding (lookup)
import Web.Rails.Session.Types
import Crypto.Cipher.AES (AES256)
import Crypto.Cipher.AESGCMSIV qualified as AESGCM
import Crypto.Cipher.Types (cbcDecrypt, cipherInit, makeIV, aeadInit, AEADMode (..), aeadSimpleDecrypt, AuthTag(..))
import Crypto.Error (CryptoFailable(CryptoFailed, CryptoPassed))

data DecodingError
  = InvalidCookieFormat
  | InvalidAuthTagSize Int
  | InvalidIVSize Int
  | InvalidJSON String
  | InvalidBase64 String
  | InvalidCryptoStep String
  | MakeIVFailed String
  | DecryptionIsEmpty
  deriving (Show, Eq)

-- EXPORTS

-- | Decode a cookie encrypted by Rails.
decode :: Maybe Salt
       -> SecretKeyBase
       -> Cookie
       -> Maybe JSON.Value
decode mbSalt secretKeyBase cookie =
  either (const Nothing) Just (decodeEither mbSalt secretKeyBase cookie)

-- | Decode a cookie encrypted by Rails and retain some error information on failure.
decodeEither :: Maybe Salt
             -> SecretKeyBase
             -> Cookie
             -> Either DecodingError JSON.Value
decodeEither mbSalt secretKeyBase cookie = do
  case decrypt mbSalt secretKeyBase cookie of
    Left errorMessage ->
      Left errorMessage
    Right (DecryptedData deData) ->
      first InvalidJSON $ JSON.eitherDecode (BL.fromStrict deData)

-- | Decrypts a cookie encrypted by Rails. It returns the encrypted
-- data as a 'ByteString' blob, which is your responsibility to deserialise.
decrypt :: Maybe Salt
        -> SecretKeyBase
        -> Cookie
        -> Either DecodingError DecryptedData
decrypt mbSalt secretKeyBase cookie = do
  (EncryptedData encData, InitVector ivVec, autTag) <- prepare cookie
  nonce <- doCryptoStep $ AESGCM.nonce ivVec
  cipher <- doCryptoStep (cipherInit cipherKey :: CryptoFailable AES256)
  aad <- doCryptoStep (aeadInit AEAD_GCM cipher ivVec)
  case aeadSimpleDecrypt aad (mempty :: ByteString) encData autTag of
    Nothing -> Left DecryptionIsEmpty
    Just dt -> Right $ DecryptedData dt

  where

    cipherKey :: ByteString
    (SecretKey cipherKey) = generateSecret salt secretKeyBase

    salt :: Salt
    salt = fromMaybe defaultSalt mbSalt

    defaultSalt :: Salt
    defaultSalt = Salt "authenticated encrypted cookie"

doCryptoStep :: CryptoFailable a -> Either DecodingError a
doCryptoStep = \case
  CryptoFailed errorMessage ->
    Left (InvalidCryptoStep $ show errorMessage)
  CryptoPassed a -> Right a

-- PRIVATE

-- | Generate secret key using same cryptographic routines as Rails.
generateSecret :: Salt -> SecretKeyBase -> SecretKey
generateSecret (Salt salt) (SecretKeyBase secret) =
  SecretKey $! sha256PBKDF2 secret salt 1000 32

-- | Prepare a cookie for decryption.
-- /NOTE/: Unlike Rails4, Rails7 cookies contains a final auth tag at the end.
prepare :: Cookie -> Either DecodingError (EncryptedData, InitVector, AuthTag)
prepare (Cookie cookie) =
  case tokenise "--" cookie of
    [encDataB64, ivVectorB64, autTagB64]
     -> do
       encData  <- base64decode encDataB64
       ivVector <- enforceIVLen     =<< base64decode ivVectorB64
       autTag   <- enforceAutTagLen =<< base64decode autTagB64
       Right (EncryptedData encData, InitVector ivVector, AuthTag $ BA.convert autTag)
    _ -> Left InvalidCookieFormat
  where
    base64decode :: ByteString -> Either DecodingError ByteString
    base64decode = Right . B64.decodeLenient . C8.filter (/= '\n')

    enforceAutTagLen :: ByteString -> Either DecodingError ByteString
    enforceAutTagLen b
      | BS.length b == 16
      = Right b
      | otherwise
      = Left $ InvalidAuthTagSize (BS.length b)

    enforceIVLen :: ByteString -> Either DecodingError ByteString
    enforceIVLen b
      | BS.length b == 12
      = Right b
      | otherwise
      = Left $ InvalidIVSize (BS.length b)


separator :: ByteString
separator = "--"

tokenise :: ByteString -> ByteString -> [ByteString]
tokenise x y = h : if BS.null t then [] else tokenise x (BS.drop (BS.length x) t)
    where (h,t) = BS.breakSubstring x y