rails-session (empty) → 0.1.0.0
raw patch · 5 files changed
+348/−0 lines, 5 filesdep +basedep +base-compatdep +base64-bytestringsetup-changed
Dependencies added: base, base-compat, base64-bytestring, bytestring, cryptonite, http-types, pbkdf, rails-session, ruby-marshal, string-conv, tasty, tasty-hspec, vector
Files
- LICENSE +30/−0
- Setup.hs +2/−0
- rails-session.cabal +51/−0
- src/Web/Rails/Session.hs +174/−0
- test/Spec.hs +91/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2016-2017 Philip Cunningham & Alfredo di Napoli++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Author name here nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ rails-session.cabal view
@@ -0,0 +1,51 @@+name: rails-session+version: 0.1.0.0+synopsis: Decrypt Ruby on Rails sessions in Haskell+description: Please see README.md+homepage: http://github.com/iconnect/rails-session#readme+license: BSD3+license-file: LICENSE+author: Philip Cunningham & Alfredo di Napoli+maintainer: philip@irisconnect.co.uk+copyright: 2016-2017 Philip Cunningham & Alfredo di Napoli+category: Web+build-type: Simple+cabal-version: >=1.10++Source-repository head+ type: git+ location: https://github.com/iconnect/rails-session++library+ hs-source-dirs: src+ exposed-modules: Web.Rails.Session+ build-depends: base >= 4.7 && < 5+ , base-compat >= 0.8.2+ , base64-bytestring >= 1.0.0.1+ , bytestring >= 0.10.6.0+ , cryptonite >= 0.6+ , http-types >= 0.8.6+ , pbkdf >= 1.1.1.1+ , ruby-marshal >= 0.1.1+ , string-conv >= 0.1+ , vector >= 0.10.12.3+ default-language: Haskell2010++test-suite specs+ ghc-options: -Wall+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Spec.hs+ build-depends: base+ , bytestring+ , rails-session+ , ruby-marshal+ , tasty+ , tasty-hspec+ , vector+ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ default-language: Haskell2010++source-repository head+ type: git+ location: https://github.com/iconnect/haskell-oss/rails-interop
+ src/Web/Rails/Session.hs view
@@ -0,0 +1,174 @@+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE PackageImports #-}+{-# LANGUAGE ScopedTypeVariables #-}++module Web.Rails.Session (+ -- * Decoding+ decode+ , decodeEither+ -- * Decrypting+ , decrypt+ -- * Utilities+ , lookupString+ , lookupFixnum+ -- * Lifting weaker types into stronger types+ , Cookie+ , mkCookie+ , Salt+ , mkSalt+ , SecretKeyBase+ , mkSecretKeyBase+ ) 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++newtype DecryptedData =+ DecryptedData ByteString+ deriving (Show, Ord, Eq)++newtype EncryptedData =+ EncryptedData ByteString+ deriving (Show, Ord, Eq)++newtype InitVector =+ InitVector ByteString+ deriving (Show, Ord, Eq)++newtype Cookie =+ Cookie ByteString+ deriving (Show, Ord, Eq)++newtype Salt =+ Salt ByteString+ deriving (Show, Ord, Eq)++newtype SecretKey =+ SecretKey ByteString+ deriving (Show, Ord, Eq)++newtype SecretKeyBase =+ SecretKeyBase ByteString+ deriving (Show, Ord, Eq)++-- SMART CONSTRUCTORS++mkCookie :: ByteString -> Cookie+mkCookie = Cookie++mkSalt :: ByteString -> Salt+mkSalt = Salt++mkSecretKeyBase :: ByteString -> SecretKeyBase+mkSecretKeyBase = SecretKeyBase++-- 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 (Salt "encrypted cookie") mbSalt+ (SecretKey secret) = generateSecret salt secretKeyBase+ key = BS.take 32 secret+ (EncryptedData encData, InitVector initVec) = prepare cookie+ in case makeIV initVec of+ Nothing ->+ Left $! "Failed to build init. vector for: " <> show initVec+ Just initVec' ->+ case (cipherInit key :: CryptoFailable AES256) of+ CryptoFailed errorMessage ->+ Left (show errorMessage)+ CryptoPassed cipher ->+ Right . DecryptedData $! cbcDecrypt cipher initVec' encData++-- | Lookup integer for a given key.+lookupFixnum :: ByteString -> RubyStringEncoding -> RubyObject -> Maybe Int+lookupFixnum k enc m =+ case lookup (RIVar (RString k, enc)) m of+ Just (RFixnum v) ->+ Just v+ _ ->+ Nothing++-- | Lookup string for a given key and throw away encoding information.+lookupString :: ByteString+ -> RubyStringEncoding+ -> RubyObject+ -> Maybe ByteString+lookupString k enc m =+ case lookup (RIVar (RString k, enc)) m of+ Just (RIVar (RString v, _)) ->+ Just v+ _ ->+ Nothing++-- UTIL++-- | 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
+ test/Spec.hs view
@@ -0,0 +1,91 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++import Data.ByteString (ByteString)+import Data.Either (isRight)+import Data.Ruby.Marshal hiding (decodeEither)+import Data.Vector (fromList)+import Test.Tasty (defaultMain, testGroup)+import Test.Tasty.Hspec (describe, it, shouldBe, shouldSatisfy, testSpec, Spec)+import Web.Rails.Session++main :: IO ()+main = do+ x <- testSpec "Web.Rails.Session" specs+ defaultMain (testGroup "All the Specs" [x])++specs :: Spec+specs = do+ describe "decode" $ do+ it "should be a Right(..)" $ do+ let result = 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++ 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 csrfToken++ 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 sessionId++ 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++-- CONFIG++secret :: SecretKeyBase+secret = mkSecretKeyBase "development_secret_token"++-- VALUES++authToken :: ByteString+authToken = "GT1EYH9X8OXYqup4HwnQIvfnh59TqNys1IvukVXpXR8="++sessionId :: ByteString+sessionId = "912a0abcf3d64e0d9d2bdb601b9e8224"++csrfToken :: ByteString+csrfToken = "c9kRzi8L7oj2MPI/QlqYpQ79WR6YfKTDob6PGl9V2pg="++-- SESSIONS++cookie :: Cookie+cookie =+ mkCookie $+ "T3NpYnRsWGJsZ25qL0R4MFlZdE9wZVBrZXc3VnFHMHhYMFgvemlVUjlTekZKbERXbVd2Mkwra0xxTmNOYnZaWktBQWo3T25RV3VPRkR6RU9BQ3dub2FSWExlZmZ1RUxVWG9vZjRTbWphRzBFSW5nMVJOSklPTVRPRGxKN0tvdGxhZzVlZktLTmhxc0V1a1FCWHpwNVJGTjdON0JCbjZQWHM0R1M1SWIzeUkzalhVNWdVbnd2Z2E5RlBMSXcvR0tNSHVOZ0NiK1RBTzVxcCtMK3hJa1daYW13allNb1NyN3pOUTFBQ0tRcFNEdUVJelVMZE8rVXhwM3RhcHFONWRwby9kRStNNkRUVXNQTDNKcjF0ZWpGTUE9PS0tb3NGeEJiZ1dLeFFKcFV0WXgyUytnZz09--e996601dbf39ff5ffbf6e2f23f6ddf78c5179baa"++rubySession :: RubyObject+rubySession =+ RHash+ (fromList+ [ ( RIVar (RString "session_id", UTF_8)+ , RIVar (RString sessionId, UTF_8))+ , ( RIVar (RString "_csrf_token", US_ASCII)+ , RIVar+ (RString csrfToken, US_ASCII))+ , ( RIVar (RString "token", US_ASCII)+ , RIVar (RString authToken, UTF_8))+ ])