packages feed

stripe-signature-1.0.0.12: library/Stripe/Signature.hs

{- | https://stripe.com/docs/webhooks/signatures#verify-manually -}

module Stripe.Signature
  ( Sig (..), isSigValid, digest, signedPayload, natBytes, parseSig
  ) where

-- base
import qualified Data.List
import qualified Data.Maybe
import qualified Data.String
import           Numeric.Natural (Natural)
import qualified Text.Read

-- base16-bytestring
import qualified Data.ByteString.Base16 as Base16

-- bytestring
import Data.ByteString (ByteString)

-- cryptohash-sha256
import qualified Crypto.Hash.SHA256

-- stripe-concepts
import Stripe.Concepts (WebhookSecretKey (..))

-- text
import           Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text

isSigValid :: Sig -> WebhookSecretKey -> ByteString -> Bool
isSigValid x secret body =
    Data.List.any ((==) correctDigest) (sigV1 x)
  where
    correctDigest = digest secret (sigTime x) body

digest :: WebhookSecretKey -> Natural -> ByteString -> ByteString
digest (WebhookSecretKey secret) time body =
    Crypto.Hash.SHA256.hmac secret (signedPayload time body)

signedPayload :: Natural -> ByteString -> ByteString
signedPayload time body =
    mconcat
        [ natBytes time
        , encodeAscii "."
        , body
        ]

{- | Convert a natural number to the ASCII encoding of its decimal
representation. -}

natBytes :: Natural -> ByteString
natBytes = encodeAscii . (show :: Natural -> String)

encodeAscii :: String -> ByteString
encodeAscii = Data.String.fromString

{- | The relevant bits of data extracted from the Stripe signature header. -}

data Sig =
  Sig
    { sigTime :: Natural
    , sigV1 :: [ByteString]
    }

{- | Parse the Stripe signature header, returning 'Nothing' if parsing fails. -}

parseSig :: Text -> Maybe Sig
parseSig txt =
  let
    parts :: [(Text, Text)]
    parts = splitSig txt
  in
    do
      time <- Data.List.lookup (Text.pack "t") parts
                    >>= (readNatural . Text.unpack)

      let
          v1 = Data.Maybe.mapMaybe
              ( \(k, v) ->
                  if k == Text.pack "v1"
                  then decodeHex v
                  else Nothing
              )
              parts

      pure Sig{ sigTime = time, sigV1 = v1 }

splitSig :: Text -> [(Text, Text)]
splitSig =
    Data.Maybe.catMaybes
    . fmap (split2 (Text.pack "="))
    . Text.splitOn (Text.pack ",")

split2 :: Text -> Text -> Maybe (Text, Text)
split2 pat src =
    let
        (x, y) = Text.breakOn pat src
        y' = Text.drop (Text.length pat) y
    in
        if Text.null y then Nothing else Just (x, y')

{- | Parse a number consisting of one or more digits 0 through 9. -}

readNatural :: String -> Maybe Natural
readNatural = Text.Read.readMaybe

{- | Decodes hexidecimal text as a byte string. The result is a 'Just' value iff
the text contains an even number of characters and consists only of the digits
@0@ through @9@ and letters @a@ through @f@. -}

decodeHex :: Text -> Maybe ByteString
decodeHex = either (const Nothing) Just . Base16.decode . Text.encodeUtf8