Skip to the content.

How to Verify AWS SNS Notifications in Haskell

by @eborden on June 03, 2022

AWS SNS topics are a standard method for offloading async work in the AWS ecosystem. SDKs for Java, PHP, etc. exist. These allow consumers to verify the authenticity of SNS notifications when sending them over HTTPS. While the venerable Amazonka exists, it does not handle this use case. However, we can!

The Notifications

An SNS notification is a simple JSON payload. It includes a signature and a URL to an X509 public key, along with other details.

  {
    "Signature": "Nzk0MzdGNUVEREExM0Y5QzA2NjlCOTc4REQ3QTkwNjZERDIwNTlGMQ==",
    "SigningCertURL": "https://example.com/cert.pem",
    ...
  }

Parsing these messages is an easy task, but machinery for verifying their X509 signatures requires a bit more of a dive.

Can I X509?

We are in luck. The Haskell ecosystem has all the libraries we need to verify these signatures.

Getting the Certificate

⚠️ Partial functions, fromRight and head, used here for brevity.

We need to fetch the certificate from our JSON payload. First a few imports from our friendly ecosystem.

import Network.HTTP.Simple (getResponseBody, httpLbs, parseRequest_)
import Data.PEM (pemContent, pemParseLBS)
import Data.X509 (decodeSignedCertificate, SignedCertificate)

Next we leverage those libraries to fetch the PEM,

response <- httpLbs $ parseRequest_ snsSigningCertURL

parse its contents,

let pems = fromRight $ pemParseLBS $ getResponseBody response

extract a certificate,

let cert = pemContent $ head pems

and decode it into a SignedCertificate.

decodeSignedCertificate cert

Pulling it all together:

retrieveCertificate :: MonadIO m => SNSPayload -> m SignedCertificate
retrieveCertificate SNSPayload {snsSigningCertURL} = do
  response <- httpLbs $ parseRequest_ snsSigningCertURL
  let
    pems = fromRight $ pemParseLBS $ getResponseBody response
    cert = pemContent $ head pems
  pure $ fromRight $ decodeSignedCertificate cert

Validating The Signature

Once we have a SignedCertificate, we can calculate the unsigned signature and verify it against the signature we recieved from AWS.

Again, a few imports we’ll need.

import Data.ByteArray.Encoding (Base(Base64), convertFromBase)
import Data.PEM (pemContent, pemParseLBS)
import Data.Text.Encoding (encodeUtf8)
import Data.X509
  ( HashALG(HashSHA1)
  , PubKeyALG(PubKeyALG_RSA)
  , SignatureALG(SignatureALG)
  , certPubKey
  , getCertificate
  )
import Data.X509.Validation (SignatureVerification, verifySignature)

We must convert the signature from base64 encoding,

let signature = fromRight $ convertFromBase Base64 $ encodeUtf8 snsSignature

and extract a public key from our signed certificate.

let publicKey = certPubKey $ getCertificate signedCert

Then we can compare the signed and unsigned signatures using the RSA encryption and SHA1 hashing AWS uses to produce the public and private keys.

let algorithm = SignatureALG HashSHA1 PubKeyALG_RSA
in verifySignature
    algorithm
    publicKey
    (unsignedSignature payload)
    signature

Bringing it all together, we’ve leveraged the robust Haskell ecosystem to do a huge amount of persnickity work for us.

validateSnsMessage :: MonadIO m => SNSPayload -> m SignatureVerification
validateSnsMessage payload@SNSPayload {..} = do
  signedCert <- retrieveCertificate payload
  let
    signature = fromRight $ convertFromBase Base64 $ encodeUtf8 snsSignature
    publicKey = certPubKey $ getCertificate signedCert
    algorithm = SignatureALG HashSHA1 PubKeyALG_RSA
  pure $ verifySignature
    algorithm
    publicKey
    (unsignedSignature payload)
    signature

unsignedSignature :: SNSPayload -> ByteString
unsignedSignature SNSPayload{..} =
  encodeUtf8 $ mconcat [snsMessage {- etc -}]

But There’s More

The better news is you don’t have to implement this yourself. We have open sourced our AWS SNS handling in the aws-sns-verify package. This package includes parsing, verification, and subscription handling. Everything you need to quickly setup an SNS webhook. Enjoy!

import Amazon.SNS.Verify (verifySNSMessage)

handler :: Handler ()
handler = do
  message <- verifySNSMessage =<< requireInsecureJsonBody
  logDebugN message