rails-session 0.1.3.0 → 0.1.4.0
raw patch · 10 files changed
+478/−310 lines, 10 filesdep +aesondep +aeson-qqdep +cryptondep −cryptonitedep ~bytestringnew-uploaderPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
Dependencies added: aeson, aeson-qq, crypton, memory, text
Dependencies removed: cryptonite
Dependency ranges changed: bytestring
API changes (from Hackage documentation)
- Web.Rails.Session: csrfToken :: RubyObject -> Maybe ByteString
- Web.Rails.Session: data Cookie
- Web.Rails.Session: data DecryptedData
- Web.Rails.Session: data Salt
- Web.Rails.Session: data SecretKeyBase
- Web.Rails.Session: decode :: Maybe Salt -> SecretKeyBase -> Cookie -> Maybe RubyObject
- Web.Rails.Session: decodeEither :: Maybe Salt -> SecretKeyBase -> Cookie -> Either String RubyObject
- Web.Rails.Session: decrypt :: Maybe Salt -> SecretKeyBase -> Cookie -> Either String DecryptedData
- Web.Rails.Session: instance GHC.Classes.Eq Web.Rails.Session.Cookie
- Web.Rails.Session: instance GHC.Classes.Eq Web.Rails.Session.DecryptedData
- Web.Rails.Session: instance GHC.Classes.Eq Web.Rails.Session.EncryptedData
- Web.Rails.Session: instance GHC.Classes.Eq Web.Rails.Session.InitVector
- Web.Rails.Session: instance GHC.Classes.Eq Web.Rails.Session.Salt
- Web.Rails.Session: instance GHC.Classes.Eq Web.Rails.Session.SecretKey
- Web.Rails.Session: instance GHC.Classes.Eq Web.Rails.Session.SecretKeyBase
- Web.Rails.Session: instance GHC.Classes.Ord Web.Rails.Session.Cookie
- Web.Rails.Session: instance GHC.Classes.Ord Web.Rails.Session.DecryptedData
- Web.Rails.Session: instance GHC.Classes.Ord Web.Rails.Session.EncryptedData
- Web.Rails.Session: instance GHC.Classes.Ord Web.Rails.Session.InitVector
- Web.Rails.Session: instance GHC.Classes.Ord Web.Rails.Session.Salt
- Web.Rails.Session: instance GHC.Classes.Ord Web.Rails.Session.SecretKey
- Web.Rails.Session: instance GHC.Classes.Ord Web.Rails.Session.SecretKeyBase
- Web.Rails.Session: instance GHC.Show.Show Web.Rails.Session.Cookie
- Web.Rails.Session: instance GHC.Show.Show Web.Rails.Session.DecryptedData
- Web.Rails.Session: instance GHC.Show.Show Web.Rails.Session.EncryptedData
- Web.Rails.Session: instance GHC.Show.Show Web.Rails.Session.InitVector
- Web.Rails.Session: instance GHC.Show.Show Web.Rails.Session.Salt
- Web.Rails.Session: instance GHC.Show.Show Web.Rails.Session.SecretKey
- Web.Rails.Session: instance GHC.Show.Show Web.Rails.Session.SecretKeyBase
- Web.Rails.Session: lookupFixnum :: ByteString -> RubyStringEncoding -> RubyObject -> Maybe Int
- Web.Rails.Session: lookupString :: ByteString -> RubyStringEncoding -> RubyObject -> Maybe ByteString
- Web.Rails.Session: mkCookie :: ByteString -> Cookie
- Web.Rails.Session: mkSalt :: ByteString -> Salt
- Web.Rails.Session: mkSecretKeyBase :: ByteString -> SecretKeyBase
- Web.Rails.Session: sessionId :: RubyObject -> Maybe ByteString
- Web.Rails.Session: unwrapDecryptedData :: DecryptedData -> ByteString
- Web.Rails3.Session: Cookie :: ByteString -> Cookie
- Web.Rails3.Session: Secret :: ByteString -> Secret
- Web.Rails3.Session: newtype Cookie
- Web.Rails3.Session: newtype Secret
+ Web.Rails.Session.Types: Cookie :: ByteString -> Cookie
+ Web.Rails.Session.Types: DecryptedData :: ByteString -> DecryptedData
+ Web.Rails.Session.Types: EncryptedData :: ByteString -> EncryptedData
+ Web.Rails.Session.Types: InitVector :: ByteString -> InitVector
+ Web.Rails.Session.Types: Salt :: ByteString -> Salt
+ Web.Rails.Session.Types: Secret :: ByteString -> Secret
+ Web.Rails.Session.Types: SecretKey :: ByteString -> SecretKey
+ Web.Rails.Session.Types: SecretKeyBase :: ByteString -> SecretKeyBase
+ Web.Rails.Session.Types: instance GHC.Classes.Eq Web.Rails.Session.Types.Cookie
+ Web.Rails.Session.Types: instance GHC.Classes.Eq Web.Rails.Session.Types.DecryptedData
+ Web.Rails.Session.Types: instance GHC.Classes.Eq Web.Rails.Session.Types.EncryptedData
+ Web.Rails.Session.Types: instance GHC.Classes.Eq Web.Rails.Session.Types.InitVector
+ Web.Rails.Session.Types: instance GHC.Classes.Eq Web.Rails.Session.Types.Salt
+ Web.Rails.Session.Types: instance GHC.Classes.Eq Web.Rails.Session.Types.SecretKey
+ Web.Rails.Session.Types: instance GHC.Classes.Eq Web.Rails.Session.Types.SecretKeyBase
+ Web.Rails.Session.Types: instance GHC.Classes.Ord Web.Rails.Session.Types.Cookie
+ Web.Rails.Session.Types: instance GHC.Classes.Ord Web.Rails.Session.Types.DecryptedData
+ Web.Rails.Session.Types: instance GHC.Classes.Ord Web.Rails.Session.Types.EncryptedData
+ Web.Rails.Session.Types: instance GHC.Classes.Ord Web.Rails.Session.Types.InitVector
+ Web.Rails.Session.Types: instance GHC.Classes.Ord Web.Rails.Session.Types.Salt
+ Web.Rails.Session.Types: instance GHC.Classes.Ord Web.Rails.Session.Types.SecretKey
+ Web.Rails.Session.Types: instance GHC.Classes.Ord Web.Rails.Session.Types.SecretKeyBase
+ Web.Rails.Session.Types: instance GHC.Show.Show Web.Rails.Session.Types.Cookie
+ Web.Rails.Session.Types: instance GHC.Show.Show Web.Rails.Session.Types.DecryptedData
+ Web.Rails.Session.Types: instance GHC.Show.Show Web.Rails.Session.Types.EncryptedData
+ Web.Rails.Session.Types: instance GHC.Show.Show Web.Rails.Session.Types.InitVector
+ Web.Rails.Session.Types: instance GHC.Show.Show Web.Rails.Session.Types.Salt
+ Web.Rails.Session.Types: instance GHC.Show.Show Web.Rails.Session.Types.SecretKey
+ Web.Rails.Session.Types: instance GHC.Show.Show Web.Rails.Session.Types.SecretKeyBase
+ Web.Rails.Session.Types: mkCookie :: ByteString -> Cookie
+ Web.Rails.Session.Types: mkSalt :: ByteString -> Salt
+ Web.Rails.Session.Types: mkSecretKeyBase :: ByteString -> SecretKeyBase
+ Web.Rails.Session.Types: newtype Cookie
+ Web.Rails.Session.Types: newtype DecryptedData
+ Web.Rails.Session.Types: newtype EncryptedData
+ Web.Rails.Session.Types: newtype InitVector
+ Web.Rails.Session.Types: newtype Salt
+ Web.Rails.Session.Types: newtype Secret
+ Web.Rails.Session.Types: newtype SecretKey
+ Web.Rails.Session.Types: newtype SecretKeyBase
+ Web.Rails.Session.Types: unwrapDecryptedData :: DecryptedData -> ByteString
+ Web.Rails4.Session: csrfToken :: RubyObject -> Maybe ByteString
+ Web.Rails4.Session: decode :: Maybe Salt -> SecretKeyBase -> Cookie -> Maybe RubyObject
+ Web.Rails4.Session: decodeEither :: Maybe Salt -> SecretKeyBase -> Cookie -> Either String RubyObject
+ Web.Rails4.Session: decrypt :: Maybe Salt -> SecretKeyBase -> Cookie -> Either String DecryptedData
+ Web.Rails4.Session: lookupFixnum :: ByteString -> RubyStringEncoding -> RubyObject -> Maybe Int
+ Web.Rails4.Session: lookupString :: ByteString -> RubyStringEncoding -> RubyObject -> Maybe ByteString
+ Web.Rails4.Session: sessionId :: RubyObject -> Maybe ByteString
+ Web.Rails7.Session: DecryptionIsEmpty :: DecodingError
+ Web.Rails7.Session: InvalidAuthTagSize :: Int -> DecodingError
+ Web.Rails7.Session: InvalidBase64 :: String -> DecodingError
+ Web.Rails7.Session: InvalidCookieFormat :: DecodingError
+ Web.Rails7.Session: InvalidCryptoStep :: String -> DecodingError
+ Web.Rails7.Session: InvalidIVSize :: Int -> DecodingError
+ Web.Rails7.Session: InvalidJSON :: String -> DecodingError
+ Web.Rails7.Session: MakeIVFailed :: String -> DecodingError
+ Web.Rails7.Session: data DecodingError
+ Web.Rails7.Session: decode :: Maybe Salt -> SecretKeyBase -> Cookie -> Maybe Value
+ Web.Rails7.Session: decodeEither :: Maybe Salt -> SecretKeyBase -> Cookie -> Either DecodingError Value
+ Web.Rails7.Session: decrypt :: Maybe Salt -> SecretKeyBase -> Cookie -> Either DecodingError DecryptedData
+ Web.Rails7.Session: instance GHC.Classes.Eq Web.Rails7.Session.DecodingError
+ Web.Rails7.Session: instance GHC.Show.Show Web.Rails7.Session.DecodingError
Files
- rails-session.cabal +21/−11
- src/Web/Rails/Session.hs +0/−210
- src/Web/Rails/Session/Types.hs +62/−0
- src/Web/Rails3/Session.hs +1/−5
- src/Web/Rails4/Session.hs +143/−0
- src/Web/Rails7/Session.hs +149/−0
- test/Rails3 +1/−0
- test/Rails4 +1/−0
- test/Rails7 +1/−0
- test/Spec.hs +99/−84
rails-session.cabal view
@@ -1,5 +1,5 @@ name: rails-session-version: 0.1.3.0+version: 0.1.4.0 synopsis: Decrypt Ruby on Rails sessions in Haskell description: Please see README.md homepage: http://github.com/iconnect/rails-session#readme@@ -12,6 +12,8 @@ build-type: Simple cabal-version: >=1.10 extra-source-files: CHANGELOG.md+tested-with: GHC ==9.4.8 || ==9.6.4+data-files: test/Rails3 test/Rails4 test/Rails7 Source-repository head type: git@@ -19,40 +21,48 @@ library hs-source-dirs: src- exposed-modules: Web.Rails.Session+ exposed-modules: Web.Rails.Session.Types+ , Web.Rails4.Session , Web.Rails3.Session- build-depends: base >= 4.7 && < 5+ , Web.Rails7.Session+ build-depends: aeson <= 3.0+ , base >= 4.7 && < 5 , base-compat >= 0.8.2+ , base16-bytestring >= 1.0.1.0 , base64-bytestring >= 1.0.0.1+ , bytestring , bytestring >= 0.10.6.0- , cryptonite >= 0.6+ , containers+ , crypton >= 1.0.0 , http-types >= 0.8.6+ , memory , pbkdf >= 1.1.1.1 , ruby-marshal >= 0.1.1 , string-conv >= 0.1+ , text < 3 , vector >= 0.10.12.3- , bytestring- , base16-bytestring >= 1.0.1.0- , containers if impl(ghc < 8.0) build-depends: semigroups- default-language: Haskell2010+ default-language: GHC2021 test-suite specs ghc-options: -Wall type: exitcode-stdio-1.0 hs-source-dirs: test main-is: Spec.hs- build-depends: base+ build-depends: aeson+ , aeson-qq+ , base , bytestring , filepath , hspec- , semigroups , rails-session , ruby-marshal+ , semigroups , tasty , tasty-hspec+ , text , transformers , vector ghc-options: -threaded -rtsopts -with-rtsopts=-N- default-language: Haskell2010+ default-language: GHC2021
− src/Web/Rails/Session.hs
@@ -1,210 +0,0 @@-{-# LANGUAGE NoImplicitPrelude #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE PackageImports #-}-{-# LANGUAGE ScopedTypeVariables #-}--module Web.Rails.Session (- -- * Decoding- decode- , decodeEither- -- * Decrypting- , decrypt- -- * Utilities- , csrfToken- , sessionId- , lookupString- , lookupFixnum- -- * Lifting weaker types into stronger types- , Cookie- , mkCookie- , Salt- , mkSalt- , SecretKeyBase- , mkSecretKeyBase- , DecryptedData- , unwrapDecryptedData- ) where--import Control.Applicative ((<$>))-import "cryptonite" Crypto.Cipher.AES (AES256)-import "cryptonite" Crypto.Cipher.Types (cbcDecrypt, cipherInit, makeIV)-import "cryptonite" Crypto.Error (CryptoFailable(CryptoFailed, CryptoPassed))-import Crypto.PBKDF.ByteString (sha1PBKDF2)-import Data.ByteString (ByteString)-import qualified Data.ByteString as BS-import qualified Data.ByteString.Base64 as B64-import Data.Either (Either(..), either)-import Data.Function.Compat ((&))-import Data.Maybe (Maybe(..), fromMaybe)-import Data.Monoid ((<>))-import Data.Ruby.Marshal (RubyObject(..), RubyStringEncoding(..))-import qualified Data.Ruby.Marshal as Ruby-import Data.String.Conv (toS)-import qualified Data.Vector as Vec-import Network.HTTP.Types (urlDecode)-import Prelude (Bool(..), Eq, Int, Ord, Show, String, ($!), (.)- , (==), const, error, fst, show, snd)---- TYPES---- | Wrapper around data after it has been decrypted.-newtype DecryptedData =- DecryptedData ByteString- deriving (Show, Ord, Eq)---- | Wrapper around data before it has been decrypted.-newtype EncryptedData =- EncryptedData ByteString- deriving (Show, Ord, Eq)---- | Wrapper around initialisation vector.-newtype InitVector =- InitVector ByteString- deriving (Show, Ord, Eq)---- | Wrapper around raw cookie.-newtype Cookie =- Cookie ByteString- deriving (Show, Ord, Eq)---- | Wrapper around salt.-newtype Salt =- Salt ByteString- deriving (Show, Ord, Eq)---- | Wrapper around secret.-newtype SecretKey =- SecretKey ByteString- deriving (Show, Ord, Eq)---- | Wrapper around secret key base.-newtype SecretKeyBase =- SecretKeyBase ByteString- deriving (Show, Ord, Eq)---- SMART CONSTRUCTORS---- | Lift a cookie into a richer type.-mkCookie :: ByteString -> Cookie-mkCookie = Cookie---- | Lift salt into a richer type.-mkSalt :: ByteString -> Salt-mkSalt = Salt---- | Lifts secret into a richer type.-mkSecretKeyBase :: ByteString -> SecretKeyBase-mkSecretKeyBase = SecretKeyBase---- SMART DESTRUCTORS--unwrapDecryptedData :: DecryptedData -> ByteString-unwrapDecryptedData (DecryptedData deData) =- deData---- EXPORTS---- | Decode a cookie encrypted by Rails.-decode :: Maybe Salt- -> SecretKeyBase- -> Cookie- -> Maybe RubyObject-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 String RubyObject-decodeEither mbSalt secretKeyBase cookie = do- case decrypt mbSalt secretKeyBase cookie of- Left errorMessage ->- Left errorMessage- Right (DecryptedData deData) ->- Ruby.decodeEither deData---- | Decrypts a cookie encrypted by Rails. Use this if you are using a--- serialisation format other than Ruby's Marshal format.-decrypt :: Maybe Salt- -> SecretKeyBase- -> Cookie- -> Either String DecryptedData-decrypt mbSalt secretKeyBase cookie =- let salt = fromMaybe defaultSalt mbSalt- (SecretKey secret) = generateSecret salt secretKeyBase- (EncryptedData encData, InitVector initVec) = prepare cookie- in case makeIV initVec of- Nothing ->- Left $! "Failed to build init. vector for: " <> show initVec- Just initVec' -> do- let key = BS.take 32 secret- case (cipherInit key :: CryptoFailable AES256) of- CryptoFailed errorMessage ->- Left (show errorMessage)- CryptoPassed cipher ->- Right . DecryptedData $! cbcDecrypt cipher initVec' encData- where- defaultSalt :: Salt- defaultSalt = Salt "encrypted cookie"---- UTIL---- | Helper function for looking up the csrf token in a cooie.-csrfToken :: RubyObject -> Maybe ByteString-csrfToken = lookupString "_csrf_token" US_ASCII---- | Helper function for looking up the session id in a cookie.-sessionId :: RubyObject -> Maybe ByteString-sessionId = lookupString "session_id" UTF_8---- | Lookup integer for a given key.-lookupFixnum :: ByteString -> RubyStringEncoding -> RubyObject -> Maybe Int-lookupFixnum key enc rubyObject =- case lookup (RIVar (RString key, enc)) rubyObject of- Just (RFixnum val) ->- Just val- _ ->- Nothing---- | Lookup string for a given key and throw away encoding information.-lookupString :: ByteString- -> RubyStringEncoding- -> RubyObject- -> Maybe ByteString-lookupString key enc rubyObject =- case lookup (RIVar (RString key, enc)) rubyObject of- Just (RIVar (RString val, _)) ->- Just val- _ ->- Nothing---- PRIVATE---- | Generate secret key using same cryptographic routines as Rails.-generateSecret :: Salt -> SecretKeyBase -> SecretKey-generateSecret (Salt salt) (SecretKeyBase secret) =- SecretKey $! sha1PBKDF2 secret salt 1000 64---- | Prepare a cookie for decryption.-prepare :: Cookie -> (EncryptedData, InitVector)-prepare (Cookie cookie) =- urlDecode True cookie- & (fst . split)- & base64decode- & split- & (\(x, y) -> (EncryptedData (base64decode x), InitVector (base64decode y)))- where- base64decode :: ByteString -> ByteString- base64decode = B64.decodeLenient-- separator :: ByteString- separator = "--"-- split :: ByteString -> (ByteString, ByteString)- split = BS.breakSubstring separator---- | Lookup value for a given key.-lookup :: RubyObject -> RubyObject -> Maybe RubyObject-lookup key (RHash vec) = snd <$> Vec.find (\element -> fst element == key) vec-lookup _ _ = Nothing
+ src/Web/Rails/Session/Types.hs view
@@ -0,0 +1,62 @@+module Web.Rails.Session.Types where++import Data.ByteString (ByteString)++-- TYPES++newtype Secret = Secret ByteString++-- | Wrapper around data after it has been decrypted.+newtype DecryptedData =+ DecryptedData ByteString+ deriving (Show, Ord, Eq)++-- | Wrapper around data before it has been decrypted.+newtype EncryptedData =+ EncryptedData ByteString+ deriving (Show, Ord, Eq)++-- | Wrapper around initialisation vector.+newtype InitVector =+ InitVector ByteString+ deriving (Show, Ord, Eq)++-- | Wrapper around raw cookie.+newtype Cookie =+ Cookie ByteString+ deriving (Show, Ord, Eq)++-- | Wrapper around salt.+newtype Salt =+ Salt ByteString+ deriving (Show, Ord, Eq)++-- | Wrapper around secret.+newtype SecretKey =+ SecretKey ByteString+ deriving (Show, Ord, Eq)++-- | Wrapper around secret key base.+newtype SecretKeyBase =+ SecretKeyBase ByteString+ deriving (Show, Ord, Eq)++-- SMART CONSTRUCTORS++-- | Lift a cookie into a richer type.+mkCookie :: ByteString -> Cookie+mkCookie = Cookie++-- | Lift salt into a richer type.+mkSalt :: ByteString -> Salt+mkSalt = Salt++-- | Lifts secret into a richer type.+mkSecretKeyBase :: ByteString -> SecretKeyBase+mkSecretKeyBase = SecretKeyBase++-- SMART DESTRUCTORS++unwrapDecryptedData :: DecryptedData -> ByteString+unwrapDecryptedData (DecryptedData deData) =+ deData
src/Web/Rails3/Session.hs view
@@ -13,8 +13,6 @@ , lookupUserIds -- * Throw-away data-types -- $datatypes- , Secret(..)- , Cookie(..) ) where @@ -28,6 +26,7 @@ import Data.Ruby.Marshal as Marshal hiding (decode, decodeEither) import qualified Data.Ruby.Marshal as Marshal (decodeEither) import Data.Ruby.Marshal.RubyObject+import Web.Rails.Session.Types import Network.HTTP.Types (urlDecode) import Data.List.NonEmpty as NE import Data.List as DL@@ -66,9 +65,6 @@ -- These data-types exist only as a way to semantically differentiate between -- various ByteString arguments when they are passed to functions. This is required -- only because Haskell doesn't have proper keywords-arguments.--newtype Secret = Secret ByteString-newtype Cookie = Cookie ByteString maybeToEither :: a -> Maybe b -> Either a b maybeToEither _ (Just b) = Right b
+ src/Web/Rails4/Session.hs view
@@ -0,0 +1,143 @@+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++module Web.Rails4.Session (+ -- * Decoding+ decode+ , decodeEither+ -- * Decrypting+ , decrypt+ -- * Utilities+ , csrfToken+ , sessionId+ , lookupString+ , lookupFixnum+ ) where++import Control.Applicative ((<$>))+import Crypto.PBKDF.ByteString (sha1PBKDF2)+import Data.ByteString (ByteString)+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 Network.HTTP.Types (urlDecode)+import Prelude (Bool(..), Eq, Int, Ord, Show, String, ($!), (.) , (==), const, error, fst, show, snd)+import Web.Rails.Session.Types+import Crypto.Cipher.AES (AES256)+import Crypto.Cipher.Types (cbcDecrypt, cipherInit, makeIV)+import Crypto.Error (CryptoFailable(CryptoFailed, CryptoPassed))+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64 as B64+import qualified Data.Ruby.Marshal as Ruby+import qualified Data.Vector as Vec++-- EXPORTS++-- | Decode a cookie encrypted by Rails.+decode :: Maybe Salt+ -> SecretKeyBase+ -> Cookie+ -> Maybe RubyObject+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 String RubyObject+decodeEither mbSalt secretKeyBase cookie = do+ case decrypt mbSalt secretKeyBase cookie of+ Left errorMessage ->+ Left errorMessage+ Right (DecryptedData deData) ->+ Ruby.decodeEither deData++-- | Decrypts a cookie encrypted by Rails. Use this if you are using a+-- serialisation format other than Ruby's Marshal format.+decrypt :: Maybe Salt+ -> SecretKeyBase+ -> Cookie+ -> Either String DecryptedData+decrypt mbSalt secretKeyBase cookie =+ let salt = fromMaybe defaultSalt mbSalt+ (SecretKey secret) = generateSecret salt secretKeyBase+ (EncryptedData encData, InitVector initVec) = prepare cookie+ in case makeIV initVec of+ Nothing ->+ Left $! "Failed to build init. vector for: " <> show initVec+ Just initVec' -> do+ let key = BS.take 32 secret+ case (cipherInit key :: CryptoFailable AES256) of+ CryptoFailed errorMessage ->+ Left (show errorMessage)+ CryptoPassed cipher ->+ Right . DecryptedData $! cbcDecrypt cipher initVec' encData+ where+ defaultSalt :: Salt+ defaultSalt = Salt "encrypted cookie"++-- UTIL++-- | Helper function for looking up the csrf token in a cooie.+csrfToken :: RubyObject -> Maybe ByteString+csrfToken = lookupString "_csrf_token" US_ASCII++-- | Helper function for looking up the session id in a cookie.+sessionId :: RubyObject -> Maybe ByteString+sessionId = lookupString "session_id" UTF_8++-- | Lookup integer for a given key.+lookupFixnum :: ByteString -> RubyStringEncoding -> RubyObject -> Maybe Int+lookupFixnum key enc rubyObject =+ case lookup (RIVar (RString key, enc)) rubyObject of+ Just (RFixnum val) ->+ Just val+ _ ->+ Nothing++-- | Lookup string for a given key and throw away encoding information.+lookupString :: ByteString+ -> RubyStringEncoding+ -> RubyObject+ -> Maybe ByteString+lookupString key enc rubyObject =+ case lookup (RIVar (RString key, enc)) rubyObject of+ Just (RIVar (RString val, _)) ->+ Just val+ _ ->+ Nothing++-- PRIVATE++-- | Generate secret key using same cryptographic routines as Rails.+generateSecret :: Salt -> SecretKeyBase -> SecretKey+generateSecret (Salt salt) (SecretKeyBase secret) =+ SecretKey $! sha1PBKDF2 secret salt 1000 64++-- | Prepare a cookie for decryption.+prepare :: Cookie -> (EncryptedData, InitVector)+prepare (Cookie cookie) =+ urlDecode True cookie+ & (fst . split)+ & base64decode+ & split+ & (\(x, y) -> (EncryptedData (base64decode x), InitVector (base64decode y)))+ where+ base64decode :: ByteString -> ByteString+ base64decode = B64.decodeLenient++ separator :: ByteString+ separator = "--"++ split :: ByteString -> (ByteString, ByteString)+ split = BS.breakSubstring separator++-- | Lookup value for a given key.+lookup :: RubyObject -> RubyObject -> Maybe RubyObject+lookup key (RHash vec) = snd <$> Vec.find (\element -> fst element == key) vec+lookup _ _ = Nothing
+ src/Web/Rails7/Session.hs view
@@ -0,0 +1,149 @@+{-# 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
+ test/Rails3 view
@@ -0,0 +1,1 @@+BAh7CEkiD3Nlc3Npb25faWQGOgZFVEkiJTNlMGQ2NzJkMmQ1OTRkMDJjOGYwMTUzODhhZTM4MGE4BjsAVEkiGXdhcmRlbi51c2VyLnVzZXIua2V5BjsAVFsHWwZpcEkiIiQyYSQxMCRoRHU3d0huZU53NEEuNldnNjFSNHZPBjsAVEkiEF9jc3JmX3Rva2VuBjsARkkiMWZLdWkyTVpWM3U1amhHQklndnZyT2k3bG8wbUQvSmdlM2UwTE9BUzE5Vmc9BjsARg==--7af4bed089e97eea2af563abe937e05263d2db4f
+ test/Rails4 view
@@ -0,0 +1,1 @@+T3NpYnRsWGJsZ25qL0R4MFlZdE9wZVBrZXc3VnFHMHhYMFgvemlVUjlTekZKbERXbVd2Mkwra0xxTmNOYnZaWktBQWo3T25RV3VPRkR6RU9BQ3dub2FSWExlZmZ1RUxVWG9vZjRTbWphRzBFSW5nMVJOSklPTVRPRGxKN0tvdGxhZzVlZktLTmhxc0V1a1FCWHpwNVJGTjdON0JCbjZQWHM0R1M1SWIzeUkzalhVNWdVbnd2Z2E5RlBMSXcvR0tNSHVOZ0NiK1RBTzVxcCtMK3hJa1daYW13allNb1NyN3pOUTFBQ0tRcFNEdUVJelVMZE8rVXhwM3RhcHFONWRwby9kRStNNkRUVXNQTDNKcjF0ZWpGTUE9PS0tb3NGeEJiZ1dLeFFKcFV0WXgyUytnZz09--e996601dbf39ff5ffbf6e2f23f6ddf78c5179baa
+ test/Rails7 view
@@ -0,0 +1,1 @@+NFvd2rISL046kEAqcX9r4G8PEt+gvSPcPMcRy72gMShbYuMZ/bktE1BtZb8APJOMYC9IgPPSLxMPrZaFPTt7LipAmJ3wKFs+QDsmz/hysPg4lDwfzWAzeeca+uQZoaNIMaoSwlm47xtLVCu9GoDjuhafEK+9kJQjOPLNzYVQDJ9U5HkEl1k5zXlU5hrvYlvd0eNbJZyO9FTtKOq+ffNzd4dAfis+l/xMy9HJQ3sl5SOnV4yr17HeWJ2FPlECtkyH/2hTPggsjm5VvJwwxC4PzF5oSmLLV6RFCdXlUNfHqxL2rwp6G7kHf2TnjfNz2fU99/SLPjEgLr8bxNFQJ9EjnlTp5Ars0ICNRdzcUTecsVieZnjL0CXqHLfk5+JmoFTr1FSwCBVYKetfqu8yiGxEsTOoxqnfvhHNnDXrCeotazaJIUi/DBeO4Z0poalxGcx/MVxgfoQecTN3a0dA+9oQHvvIc/NBL0p5b6tO4oxs8q4J3babUHAsPLeiZpFfqxk02vgLSZKV/AO4ylbRWnaLDXEIL1T/KIWdqgMMcNumriXqragavvtvIsPGOubDpCHo/1YnOCIQra2k9xI=--wkpytrsNTB4nszC0--LQ3bgugxWSkf0ZXAjYxh8g==
test/Spec.hs view
@@ -1,32 +1,41 @@ {-# LANGUAGE OverloadedStrings #-}+{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# LANGUAGE QuasiQuotes #-}+{-# HLINT ignore "Use <&>" #-} import Data.ByteString (ByteString)-import qualified Data.ByteString as BS import Data.Either (isRight)-import Data.Monoid ((<>))+import Data.Functor ((<&>)) import Data.Ruby.Marshal hiding (decodeEither) import Data.Vector (fromList)-import System.IO.Unsafe (unsafePerformIO)+import Test.Hspec (describe, it, shouldBe, shouldSatisfy, Spec) import Test.Tasty (defaultMain, testGroup) import Test.Tasty.Hspec (testSpec)-import Test.Hspec (describe, it, shouldBe, shouldSatisfy, Spec)-import Web.Rails.Session-import qualified Web.Rails3.Session as R3+import Web.Rails.Session.Types+import Data.Aeson.QQ+import qualified Data.ByteString as BS+import qualified Data.Aeson as JSON import qualified Data.List.NonEmpty as NE+import qualified Web.Rails3.Session as R3+import qualified Web.Rails4.Session as R4+import qualified Web.Rails7.Session as R7 -- SPECS main :: IO () main = do- rails4 <- testSpec "Web.Rails.Session: Rails4" $ specsFor Rails4- rails3 <- testSpec "Web.Rails3.Session: Rails4" specsForRails3- defaultMain (testGroup "All the Specs" [rails4, rails3])+ r3Cookie <- readCookie Rails3+ r4Cookie <- readCookie Rails4+ r7Cookie <- readCookie Rails7 -specsForRails3 :: Spec-specsForRails3 = do- let cookie = R3.Cookie $ unsafePerformIO $ BS.readFile "test/Rails3"- secret_ = R3.Secret "test secret token for testing cookies"+ rails4 <- testSpec "Web.Rails.Session: Rails4 (Marshal)" $ specsFor r4Cookie+ rails3 <- testSpec "Web.Rails3.Session: Rails4 (Marshal)" $ specsForRails3 r3Cookie+ rails7 <- testSpec "Web.Rails7.Session: Rails7 (JSON)" $ specsForRails7 r7Cookie+ defaultMain (testGroup "All the Specs" [rails7, rails4, rails3])++specsForRails3 :: Cookie -> Spec+specsForRails3 cookie = do+ let secret_ = Secret "test secret token for testing cookies" sessionIdVal_ = "3e0d672d2d594d02c8f015388ae380a8" csrfTokenVal_ = "fKui2MZV3u5jhGBIgvvrOi7lo0mD/Jge3e0LOAS19Vg=" userIdVal_ = 107 :: Int@@ -43,105 +52,108 @@ let result = R3.decodeEither secret_ cookie result `shouldSatisfy` isRight - it "should be a fully-formed Ruby object" $ do- case R3.decodeEither secret_ cookie of- Left e -> error $ "decodeEither failed:" ++ (show e)- Right result -> do- result `shouldBe` cookieContents_+ it "should be a fully-formed Ruby object" $ case R3.decodeEither secret_ cookie of+ Left e -> error $ "decodeEither failed:" ++ show e+ Right result -> do+ result `shouldBe` cookieContents_ - it "should look up the '_csrf_token'" $ do- case R3.decodeEither secret_ cookie of- Left e -> error $ "decodeEither failed:" ++ (show e)- Right result ->- csrfToken result `shouldBe` Just csrfTokenVal_+ it "should look up the '_csrf_token'" $ case R3.decodeEither secret_ cookie of+ Left e -> error $ "decodeEither failed:" ++ show e+ Right result ->+ R4.csrfToken result `shouldBe` Just csrfTokenVal_ - it "should look up the 'session_id'" $ do- case R3.decodeEither secret_ cookie of- Left e -> error $ "decodeEither failed:" ++ (show e)- Right result ->- sessionId result `shouldBe` Just sessionIdVal_+ it "should look up the 'session_id'" $ case R3.decodeEither secret_ cookie of+ Left e -> error $ "decodeEither failed:" ++ show e+ Right result ->+ R4.sessionId result `shouldBe` Just sessionIdVal_ - it "should look up the warden user_id(s)" $ do- case R3.decodeEither secret_ cookie of- Left e -> error $ "decodeEither failed:" ++ (show e)- Right result ->- R3.lookupUserIds result `shouldBe` Just (userIdVal_ NE.:| [] :: NE.NonEmpty Int)+ it "should look up the warden user_id(s)" $ case R3.decodeEither secret_ cookie of+ Left e -> error $ "decodeEither failed:" ++ show e+ Right result ->+ R3.lookupUserIds result `shouldBe` Just (userIdVal_ NE.:| [] :: NE.NonEmpty Int) -specsFor :: Rails -> Spec-specsFor rails = do- let cookie = unsafeReadCookie rails+specsFor :: Cookie -> Spec+specsFor cookie = do describe "decode" $ do it "should be a Right(..)" $ do- let result = decodeEither Nothing secret cookie+ let result = R4.decodeEither Nothing secret cookie result `shouldSatisfy` isRight - it "should be a fully-formed Ruby object" $ do- case decodeEither Nothing secret cookie of- Left _ -> error "decode failed"- Right result -> do- result `shouldBe` rubySession- describe "decrypt" $ do- it "should be a Right(..)" $ do- let result = decrypt Nothing secret cookie- result `shouldSatisfy` isRight+ it "should be a fully-formed Ruby object" $ case R4.decodeEither Nothing secret cookie of+ Left _ -> error "decode failed"+ Right result -> do+ result `shouldBe` rubySession+ describe "decrypt" $ it "should be a Right(..)" $ do+ let result = R4.decrypt Nothing secret cookie+ result `shouldSatisfy` isRight - describe "csrfToken" $ do- it "should look up the '_csrf_token'" $ do- case decodeEither Nothing secret cookie of- Left _ -> error "lookup failed"- Right result ->- csrfToken result `shouldBe` Just csrfTokenVal+ describe "csrfToken" $ it "should look up the '_csrf_token'" $ do+ case R4.decodeEither Nothing secret cookie of+ Left _ -> error "lookup failed"+ Right result ->+ R4.csrfToken result `shouldBe` Just csrfTokenVal - describe "sessionId" $ do- it "should look up the 'session_id'" $ do- case decodeEither Nothing secret cookie of- Left _ -> error "lookup failed"- Right result ->- sessionId result `shouldBe` Just sessionIdVal+ describe "sessionId" $ it "should look up the 'session_id'" $ do+ case R4.decodeEither Nothing secret cookie of+ Left _ -> error "lookup failed"+ Right result ->+ R4.sessionId result `shouldBe` Just sessionIdVal describe "lookupString" $ do- it "should look up the '_csrf_token'" $ do- case decodeEither Nothing secret cookie of- Left _ -> error "lookup failed"- Right result ->- lookupString "_csrf_token" US_ASCII result `shouldBe`- Just csrfTokenVal+ it "should look up the '_csrf_token'" $ case R4.decodeEither Nothing secret cookie of+ Left _ -> error "lookup failed"+ Right result ->+ R4.lookupString "_csrf_token" US_ASCII result `shouldBe`+ Just csrfTokenVal - it "should look up the 'session_id'" $ do- case decodeEither Nothing secret cookie of- Left _ -> error "lookup failed"- Right result ->- lookupString "session_id" UTF_8 result `shouldBe`- Just sessionIdVal+ it "should look up the 'session_id'" $ case R4.decodeEither Nothing secret cookie of+ Left _ -> error "lookup failed"+ Right result ->+ R4.lookupString "session_id" UTF_8 result `shouldBe`+ Just sessionIdVal - it "should look up the 'token'" $ do- case decodeEither Nothing secret cookie of- Left _ -> error "lookup failed"- Right result ->- lookupString "token" US_ASCII result `shouldBe`- Just authToken+ it "should look up the 'token'" $ case R4.decodeEither Nothing secret cookie of+ Left _ -> error "lookup failed"+ Right result ->+ R4.lookupString "token" US_ASCII result `shouldBe`+ Just authToken +specsForRails7 :: Cookie -> Spec+specsForRails7 cookie = do+ describe "decode" $ do+ it "should be a Right(..)" $ do+ let result = R7.decodeEither Nothing secret cookie+ result `shouldSatisfy` isRight++ it "should be a fully-formed JSON (Rails) object" $ do+ case R7.decodeEither Nothing secret cookie of+ Left x -> fail (show x)+ Right result -> result `shouldBe` rubyJSONObject+ -- EXAMPLES -example :: FilePath -> IO (Maybe ByteString)-example path = do+_example :: FilePath -> IO (Maybe ByteString)+_example path = do rawCookie <- BS.readFile path let appSecret = mkSecretKeyBase "development_secret_token" let cookie = mkCookie rawCookie- case decodeEither Nothing appSecret cookie of+ case R4.decodeEither Nothing appSecret cookie of Left _ -> pure Nothing Right rubyObject ->- pure $ sessionId rubyObject+ pure $ R4.sessionId rubyObject -- UTIL -data Rails = Rails4 | Rails3 deriving (Show)+data Rails =+ Rails4+ | Rails3+ | Rails7+ deriving (Show) -unsafeReadCookie :: Rails -> Cookie-unsafeReadCookie rails = unsafePerformIO $- BS.readFile ("test/" <> (show rails)) >>= pure . mkCookie+readCookie :: Rails -> IO Cookie+readCookie rails = BS.readFile ("test/" <> show rails) <&> mkCookie -- CONFIG @@ -171,3 +183,6 @@ , ( RIVar (RString "token", US_ASCII) , RIVar (RString authToken, UTF_8)) ])++rubyJSONObject :: JSON.Value+rubyJSONObject = [aesonQQ|{"_rails":{"message":"eyJzZXNzaW9uX2lkIjoiMjY1ZWY5NmVjOTIxMmM3ZGJhOWRjZTM3OGRmNDBjYjIiLCJsb2NhbGUiOiJlbl9HQiIsImJyYW5kaW5nIjoiaXJpc19jb25uZWN0IiwiY3VycmVudF9vcmdhbml6YXRpb25faWQiOjEsIl9jc3JmX3Rva2VuIjoiMTlGMkVlSmRrdUM4TzJxM2tTX3gtVnV1cWd5cWc0N2tXVVFFYW9Nbm5UNCIsInRva2VuIjoidzNSM2Q1ekJHdFFDdkw4eEZtUDlsY0g5TkVQWFprMzhTaUtBMURpWGEwUT0iLCJjdXJyZW50X3VzZXJfaWQiOjEsIm91dHN0YW5kaW5nX2V1bGFzIjpbXX0=","exp":null,"pur":"cookie._athena_new_session"}}|]