packages feed

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 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"}}|]