packages feed

wai-saml2-0.1.0.0: src/Network/Wai/SAML2/Validation.hs

--------------------------------------------------------------------------------
-- SAML2 Middleware for WAI                                                   --
--------------------------------------------------------------------------------
-- This source code is licensed under the MIT license found in the LICENSE    --
-- file in the root directory of this source tree.                            --
--------------------------------------------------------------------------------

-- | Functions to process and validate SAML2 respones.
module Network.Wai.SAML2.Validation (
    validateResponse,
    ansiX923
) where 

--------------------------------------------------------------------------------

import Control.Exception
import Control.Monad.Except

import Crypto.Error
import Crypto.Hash
import qualified Crypto.PubKey.RSA.OAEP as OAEP
import Crypto.PubKey.RSA.PKCS15 as PKCS15
import Crypto.Cipher.AES
import Crypto.Cipher.Types

import qualified Data.ByteString as BS 
import qualified Data.ByteString.Base64 as BS
import qualified Data.ByteString.Lazy as LBS 
import Data.Default.Class
import Data.Time

import Network.Wai.SAML2.XML.Encrypted
import Network.Wai.SAML2.Config
import Network.Wai.SAML2.Error
import Network.Wai.SAML2.XML
import Network.Wai.SAML2.C14N
import Network.Wai.SAML2.Response
import Network.Wai.SAML2.Assertion

import qualified Text.XML as XML
import qualified Text.XML.Cursor as XML

--------------------------------------------------------------------------------

-- | 'validateResponse' @cfg responseData@ validates a SAML2 response contained
-- in Base64-encoded @responseData@. 
validateResponse :: SAML2Config 
                 -> BS.ByteString 
                 -> IO (Either SAML2Error Assertion)
validateResponse cfg responseData = runExceptT $ do 
    -- get the current time
    now <- liftIO $ getCurrentTime 

    -- the response data is Base64-encoded; decode it
    let resXmlDocData = BS.decodeLenient responseData

    -- try to parse the XML document; throw an exception if it is not 
    -- a valid XML document
    responseXmlDoc <- case XML.parseLBS def (LBS.fromStrict resXmlDocData) of 
        Left err -> throwError $ InvalidResponseXml err 
        Right responseXmlDoc -> pure responseXmlDoc

    -- try to parse the XML document into a structured SAML2 response
    resParseResult <- liftIO $ try $ 
        parseXML (XML.fromDocument responseXmlDoc) 
        
    samlResponse <- case resParseResult of 
        Left err -> throwError $ InvalidResponse err
        Right samlResponse -> pure samlResponse 

    -- check that the response indicates success
    case responseStatusCode samlResponse of 
        Success -> pure ()
        status -> throwError $ Unsuccessful status

    -- check that the destination is as expected, if the configuration
    -- expects us to validate this 
    let destination = responseDestination samlResponse 

    case saml2ExpectedDestination cfg of 
        Just expectedDestination 
            | destination /= expectedDestination -> 
                throwError $ UnexpectedDestination destination
        _ -> pure ()

    -- check that the issuer is as expected, if the configuration
    -- expects us to validate this 
    let issuer = responseIssuer samlResponse

    case saml2ExpectedIssuer cfg of
        Just expectedIssuer
            | issuer /= expectedIssuer -> throwError $ InvalidIssuer issuer
        _ -> pure ()

    --  ***CORE VALIDATION***
    -- See https://www.w3.org/TR/xmldsig-core1/#sec-CoreValidation
    -- 
    --  *REFERENCE VALIDATION*
    -- 1. We extract the SignedInfo element from the SAML2 response's 
    -- Signature element. This element contains 
    signedInfo <- extractSignedInfo (XML.fromDocument responseXmlDoc)

    -- construct a new XML document from the SignedInfo element and render
    -- it into a textual representation
    let doc = XML.Document (XML.Prologue [] Nothing []) signedInfo []
    let signedInfoXml = XML.renderLBS def doc

    -- canonicalise the textual representation of the SignedInfo element
    signedInfoCanonResult <- liftIO $ try $ 
        canonicalise (LBS.toStrict signedInfoXml)

    normalisedSignedInfo <- case signedInfoCanonResult of 
        Left err -> throwError $ CanonicalisationFailure err
        Right result -> pure result 

    -- 2. At this point we should dereference all elements identified by
    -- Reference elements inside the SignedInfo element. However, we do
    -- not currently do that and instead just assume that there is only
    -- one Reference element which targets the overall Response. 
    -- We sanity check this, just in case we are wrong since we do not
    -- want an attacker to be able to exploit this.
    let documentId = responseId samlResponse
    let referenceId = referenceURI
                    $ signedInfoReference
                    $ signatureInfo
                    $ responseSignature samlResponse

    if documentId /= referenceId 
    then throwError $ UnexpectedReference referenceId
    else pure ()

    -- Now that we have sanity checked that we should indeed validate
    -- the entire Response, we need to remove the Signature element
    -- from it (since the Response cannot possibly have been hashed with 
    -- the Signature element present). First remove the Signature element:
    let docMinusSignature = removeSignature responseXmlDoc

    -- then render the resulting document and canonicalise it
    let renderedXml = XML.renderLBS def docMinusSignature
    refCanonResult <- liftIO $ try $ canonicalise (LBS.toStrict renderedXml)

    normalised <- case refCanonResult of 
        Left err -> throwError $ CanonicalisationFailure err
        Right result -> pure result 

    -- next, compute the hash for the normalised document and extract the
    -- existing hash from the response; both hash values must be the same
    -- or the response has been tampered with; if both hashes are the same,
    -- then the response has not been tampered with, assuming that the
    -- Signature has not been tampered with, which we validate next
    let documentHash = hashWith SHA256 normalised
    let referenceHash = digestFromByteString 
                      $ BS.decodeLenient 
                      $ referenceDigestValue 
                      $ signedInfoReference 
                      $ signatureInfo 
                      $ responseSignature samlResponse

    if Just documentHash /= referenceHash 
    then throwError InvalidDigest
    else pure ()

    --  *SIGNATURE VALIDATION*
    -- We need to check that the SignedInfo element has not been tampered 
    -- with, which we do by checking the signature contained in the response;
    -- first: extract the signature data from the response
    let sig = BS.decodeLenient $ signatureValue $ responseSignature samlResponse

    -- using the IdP's public key and the canonicalised SignedInfo element,
    -- check that the signature is correct
    let pubKey = saml2PublicKey cfg

    if PKCS15.verify (Just SHA256) pubKey normalisedSignedInfo sig 
    then pure ()
    else throwError InvalidSignature  

    --  ***ASSERTION DECRYPTION***
    -- the SAML assertion is AES-encrypted and we need to acquire the key
    -- to decrypt it; the key itself is RSA-encrypted:
    -- get the private key from the configuration and use it to decrypt
    -- the key used to decrypt the assertion
    let pk = saml2PrivateKey cfg
    let encryptedAssertion = responseEncryptedAssertion samlResponse
    
    oaepResult <- liftIO $ OAEP.decryptSafer (OAEP.defaultOAEPParams SHA1) pk 
        $ BS.decodeLenient 
        $ cipherValue 
        $ encryptedKeyCipher 
        $ encryptedAssertionKey 
        $ encryptedAssertion

    aesKey <- case oaepResult of 
        Left err -> throwError $ DecryptionFailure err
        Right cipherData -> pure cipherData

    -- next we can decrypt the assertion; initialise AES128 with 
    -- the key we have just decrypted
    xmlData <- case cipherInit aesKey of 
        CryptoFailed err -> throwError $ CryptoError err 
        CryptoPassed aes128 -> do
            -- get the AES ciphertext
            let cipherText = BS.decodeLenient 
                           $ cipherValue 
                           $ encryptedAssertionCipher 
                           $ encryptedAssertion

            -- the IV used for AES is 128bits (16 bytes) prepended
            -- to the ciphertext
            let (ivBytes, xmlBytes) = BS.splitAt 16 cipherText

            -- convert the bytes into the IV
            case makeIV ivBytes of 
                Nothing -> throwError InvalidIV
                Just iv -> do
                    -- run AES to decrypt the assertion
                    let plaintext = cbcDecrypt (aes128 :: AES128) iv xmlBytes

                    -- remove padding from the plaintext
                    case ansiX923 plaintext of 
                        Nothing -> throwError InvalidPadding
                        Just xmlData -> pure xmlData

    -- try to parse the assertion that we decrypted earlier
    assertion <- case XML.parseLBS def (LBS.fromStrict xmlData) of 
        Left err -> throwError $ InvalidAssertionXml err 
        Right assertDoc -> do
            -- try to convert the assertion document into a more
            -- structured representation
            assertParseResult <- liftIO $ try $ 
                parseXML (XML.fromDocument assertDoc)

            case assertParseResult of 
                Left err -> throwError $ InvalidAssertion err
                Right assertion -> pure assertion 

    -- validate that the assertion is valid at this point in time
    let Conditions{..} = assertionConditions assertion

    if (now < conditionsNotBefore || now >= conditionsNotOnOrAfter) &&
        not (saml2DisableTimeValidation cfg)
    then throwError NotValid
    else pure ()

    -- all checks out, return the assertion
    pure assertion

-- | 'ansiX923' @plaintext@ removes ANSI X9.23 padding from @plaintext@. 
-- See https://en.wikipedia.org/wiki/Padding_(cryptography)#ANSI_X9.23
ansiX923 :: BS.ByteString -> Maybe BS.ByteString 
ansiX923 d
    | len == 0 = Nothing 
    | padLen < 1 || padLen > len = Nothing
    | otherwise = Just content
    where len = BS.length d 
          padBytes = BS.index d (len-1)
          padLen = fromIntegral padBytes
          (content,_) = BS.splitAt (len - padLen) d

--------------------------------------------------------------------------------