wai-saml2-0.4: 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,
decodeResponse,
validateSAMLResponse,
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.PubKey.RSA.Types (PrivateKey)
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, Response))
validateResponse cfg responseData = runExceptT $ do
-- get the current time
now <- liftIO getCurrentTime
(responseXmlDoc, samlResponse) <- decodeResponse responseData
assertion <- validateSAMLResponse cfg responseXmlDoc samlResponse now
pure (assertion, samlResponse)
-- | 'decodeResponse' @responseData@ decodes a SAML2 response contained
-- in Base64-encoded @responseData@.
--
-- @since 0.4
decodeResponse :: BS.ByteString -> ExceptT SAML2Error IO (XML.Document, Response)
decodeResponse responseData = do
-- 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)
case resParseResult of
Left err -> throwError $ InvalidResponse err
Right samlResponse -> pure (responseXmlDoc, samlResponse)
-- | 'validateSAMLResponse' @cfg doc response timestamp@ validates a decoded SAML2
-- response using the given @timestamp@.
--
-- @since 0.4
validateSAMLResponse :: SAML2Config
-> XML.Document
-> Response
-> UTCTime
-> ExceptT SAML2Error IO Assertion
validateSAMLResponse cfg responseXmlDoc samlResponse now = do
-- check that the response indicates success
case statusCodeValue $ responseStatusCode samlResponse of
Success -> pure ()
_status -> throwError $ Unsuccessful $ responseStatusCode samlResponse
-- 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 <- case responseEncryptedAssertion samlResponse of
Just encrypted -> case saml2PrivateKey cfg of
Just pk -> decryptAssertion pk encrypted
Nothing -> throwError EncryptedAssertionNotSupported
Nothing
| saml2RequireEncryptedAssertion cfg -> throwError EncryptedAssertionRequired
| otherwise -> case responseAssertion samlResponse of
Just plain -> pure plain
Nothing -> throwError $ InvalidResponse $ userError "Assertion or EncryptedAssertion is required"
-- validate that the assertion is valid at this point in time
let Conditions{..} = assertionConditions assertion
-- Reference [NotBefore and NotOnOrAfter]
when ((now < conditionsNotBefore || now >= conditionsNotOnOrAfter) &&
not (saml2DisableTimeValidation cfg))
$ throwError NotValid
-- Reference [AudienceRestriction]
-- Note [Validating AudienceRestrictions]
case saml2Audiences cfg of
-- Check disabled
[] -> pure ()
ourAudiences ->
forM_ conditionsAudienceRestrictions $
\(AudienceRestriction audiences) ->
unless (any (`elem` ourAudiences) audiences)
$ throwError (AudienceMismatch audiences)
-- all checks out, return the assertion
pure assertion
-- | `decryptAssertion` @key encryptedAssertion@ decrypts the AES key in
-- @encryptedAssertion@ using `key`, then decrypts the contents using
-- the AES key.
--
-- @since 0.4
decryptAssertion :: PrivateKey -> EncryptedAssertion -> ExceptT SAML2Error IO Assertion
decryptAssertion pk encryptedAssertion = do
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
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
-- | '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
--------------------------------------------------------------------------------
-- Reference [NotBefore and NotOnOrAfter]
-- Source: https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=23
-- Section: 2.5.1.2 Attributes NotBefore and NotOnOrAfter
-- Reference [AudienceRestriction]
-- Source: https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=23
-- Section: 2.5.1.4 Elements <AudienceRestriction> and <Audience>
-- Note [Validating AudienceRestrictions]
--
-- > Note that multiple <AudienceRestriction> elements MAY be included in a single
-- > assertion, and each MUST be evaluated independently. The effect of this
-- > requirement and the preceding definition is that within a given condition,
-- > the audiences form a disjunction (an "OR") while multiple conditions form a
-- > conjunction (an "AND").
--
-- Source: https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf#page=24
-- Lines 922-925