packages feed

aws-sns-verify-0.1.0.0: library/Amazon/SNS/Verify/Validate.hs

module Amazon.SNS.Verify.Validate
  ( validateSnsMessage
  , handleSubscription
  , SNSNotificationValidationError (..)
  , ValidSNSMessage (..)
  ) where

import Amazon.SNS.Verify.Prelude

import Amazon.SNS.Verify.Payload
import Amazon.SNS.Verify.ValidURI (validRegPattern, validScheme)
import Control.Error (ExceptT, catMaybes, headMay, runExceptT, throwE)
import Control.Monad (unless, when)
import Data.ByteArray.Encoding (Base (Base64), convertFromBase)
import Data.PEM (pemContent, pemParseLBS)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.X509
  ( HashALG (..)
  , PubKeyALG (..)
  , SignatureALG (..)
  , SignedCertificate
  , certPubKey
  , decodeSignedCertificate
  , getCertificate
  )
import Data.X509.Validation
  ( SignatureFailure
  , SignatureVerification (..)
  , verifySignature
  )
import Network.HTTP.Simple
  ( getResponseBody
  , getResponseStatusCode
  , httpLbs
  , parseRequest_
  )
import Network.URI (parseURI, uriAuthority, uriRegName, uriScheme)
import Text.Regex.TDFA ((=~))

data ValidSNSMessage
  = SNSMessage Text
  | SNSSubscribe SNSSubscription
  | SNSUnsubscribe SNSSubscription
  deriving stock (Show, Eq)

-- | Validate SNS notification
--
-- SNS messages are validated through their signature. The algorithm is detailed
-- in the documentation below.
--
-- <https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html>
validateSnsMessage
  :: MonadIO m
  => SNSPayload
  -> m (Either SNSNotificationValidationError ValidSNSMessage)
validateSnsMessage payload@SNSPayload {..} = runExceptT $ do
  signature <-
    unTryE BadSignature
      $ convertFromBase Base64
      $ encodeUtf8
        snsSignature
  signedCert <- retrieveCertificate payload
  let valid =
        verifySignature
          (SignatureALG HashSHA1 PubKeyALG_RSA)
          (certPubKey $ getCertificate signedCert)
          (unsignedSignature payload)
          signature
  case valid of
    SignaturePass -> pure $ case snsTypePayload of
      Notification {} -> SNSMessage snsMessage
      SubscriptionConfirmation x -> SNSSubscribe x
      UnsubscribeConfirmation x -> SNSUnsubscribe x
    SignatureFailed e -> throwE $ InvalidPayload e

retrieveCertificate
  :: MonadIO m
  => SNSPayload
  -> ExceptT SNSNotificationValidationError m SignedCertificate
retrieveCertificate SNSPayload {..} = do
  certUrlStr <- validateCertUrl snsSigningCertURL
  response <- httpLbs $ parseRequest_ certUrlStr
  pems <- unTryE BadPem $ pemParseLBS $ getResponseBody response
  cert <-
    fromMaybeM (throwE $ BadPem "Empty List") $ pemContent <$> headMay pems
  unTryE BadCert $ decodeSignedCertificate cert

validateCertUrl
  :: Monad m => Text -> ExceptT SNSNotificationValidationError m String
validateCertUrl certUrl = do
  uri <- fromMaybeM (throwE $ BadUri certUrlStr) $ parseURI certUrlStr

  let
    scheme = uriScheme uri
    mDomain = uriRegName <$> uriAuthority uri

  unless (scheme == validScheme) $ throwE $ BadScheme scheme validScheme
  domain <- fromMaybeM (throwE $ NoAuthority certUrlStr) mDomain
  unless (domain =~ validRegPattern) $ throwE $ BadDomain domain validRegPattern

  pure certUrlStr
 where
  certUrlStr = T.unpack certUrl

unsignedSignature :: SNSPayload -> ByteString
unsignedSignature SNSPayload {..} =
  encodeUtf8
    $ mconcat
    $ (<> "\n")
    <$> catMaybes
      [ Just "Message"
      , Just snsMessage
      , Just "MessageId"
      , Just snsMessageId
      , "SubscribeURL" <$ mSubscribeUrl
      , mSubscribeUrl
      , "Subject" <$ mSubject
      , mSubject
      , Just "Timestamp"
      , Just snsTimestamp
      , "Token" <$ mToken
      , mToken
      , Just "TopicArn"
      , Just snsTopicArn
      , Just "Type"
      , Just snsType
      ]
 where
  (mSubject, mToken, mSubscribeUrl) = case snsTypePayload of
    Notification x -> (snsSubject x, Nothing, Nothing)
    SubscriptionConfirmation x ->
      (Nothing, Just $ snsToken x, Just $ snsSubscribeURL x)
    UnsubscribeConfirmation x ->
      (Nothing, Just $ snsToken x, Just $ snsSubscribeURL x)

handleSubscription
  :: MonadIO m
  => ValidSNSMessage
  -> m (Either SNSNotificationValidationError Text)
handleSubscription =
  runExceptT . \case
    SNSMessage t -> pure t
    SNSSubscribe SNSSubscription {..} -> do
      response <- httpLbs $ parseRequest_ $ T.unpack snsSubscribeURL
      when (getResponseStatusCode response >= 300) $ do
        throwE BadSubscription
      throwE SubscribeMessageResponded
    SNSUnsubscribe {} -> throwE UnsubscribeMessage

data SNSNotificationValidationError
  = BadPem String
  | BadUri String
  | BadScheme String String
  | NoAuthority String
  | BadDomain String String
  | BadSignature String
  | BadCert String
  | BadJSONParse String
  | BadSubscription
  | InvalidPayload SignatureFailure
  | MissingMessageTypeHeader
  | UnsubscribeMessage
  | SubscribeMessageResponded
  deriving stock (Show, Eq)

instance Exception SNSNotificationValidationError where
  displayException = \case
    BadScheme actual expected ->
      "BadScheme, expected "
        <> expected
        <> " but got "
        <> actual
    BadDomain actual regex ->
      "BadDomain, "
        <> actual
        <> " does not match the regular expression "
        <> regex
    ex -> show ex