packages feed

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

-- | https://stripe.com/docs/webhooks/signatures#verify-manually
module Stripe.Signature
  ( Sig (..),
    isSigValid,
    digest,
    signedPayload,
    natBytes,
    parseSig,
  )
where

import Crypto.Hash.SHA256 qualified
import Data.ByteString (ByteString)
import Data.ByteString.Base16 qualified as Base16
import Data.List qualified
import Data.Maybe qualified
import Data.String qualified
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Numeric.Natural (Natural)
import Stripe.Concepts (WebhookSecretKey (..))
import Text.Read qualified

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 hexadecimal 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