diff --git a/PaymentServer.cabal b/PaymentServer.cabal index 4bba8eea5a2d1bc944ac946fb030c539a3bc978e..56d7c013c6bb0ec8b54d3506d5d9e0c64ddb7347 100644 --- a/PaymentServer.cabal +++ b/PaymentServer.cabal @@ -17,6 +17,7 @@ library hs-source-dirs: src exposed-modules: PaymentServer.Processors.Stripe , PaymentServer.Persistence + , PaymentServer.Redemption , PaymentServer.Server , PaymentServer.Main build-depends: base >= 4.7 && < 5 @@ -30,12 +31,14 @@ library , stripe-core , text , containers + , cryptonite default-language: Haskell2010 + ghc-options: -Wmissing-import-lists -Wunused-imports executable PaymentServer-exe hs-source-dirs: app main-is: Main.hs - ghc-options: -threaded -rtsopts -with-rtsopts=-N + ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wmissing-import-lists -Wunused-imports build-depends: base , PaymentServer default-language: Haskell2010 @@ -46,9 +49,11 @@ test-suite PaymentServer-test main-is: Driver.hs other-modules: SpecStripe , SpecPersistence + , SpecRedemption , Util.WAI , Util.Gen , Util.JSON + , Util.Spec build-depends: base , PaymentServer , hspec @@ -74,7 +79,7 @@ test-suite PaymentServer-test , containers , unordered-containers , ilist - ghc-options: -threaded -rtsopts -with-rtsopts=-N + ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wmissing-import-lists -Wunused-imports default-language: Haskell2010 source-repository head diff --git a/src/PaymentServer/Main.hs b/src/PaymentServer/Main.hs index adbc78b7754d89481363380c483f17064f42c9fe..7d9ee2a744eb96d4f977e728298e21da1385742a 100644 --- a/src/PaymentServer/Main.hs +++ b/src/PaymentServer/Main.hs @@ -10,13 +10,10 @@ import Network.Wai.Handler.Warp ( run ) import Network.Wai.Middleware.RequestLogger - ( OutputFormat(Detailed, CustomOutputFormatWithDetails) + ( OutputFormat(Detailed) , outputFormat , mkRequestLogger ) -import Network.Wai.Middleware.RequestLogger.JSON - ( formatAsJSON - ) import PaymentServer.Persistence ( memory ) diff --git a/src/PaymentServer/Persistence.hs b/src/PaymentServer/Persistence.hs index eea14b43cf92b45c2977e1e6801306aa4dd194db..d08f12a67a393c8f4dea16b02a7a3ece162eb4f4 100644 --- a/src/PaymentServer/Persistence.hs +++ b/src/PaymentServer/Persistence.hs @@ -3,6 +3,7 @@ module PaymentServer.Persistence , Fingerprint , RedeemError(NotPaid, AlreadyRedeemed) , VoucherDatabase(payForVoucher, redeemVoucher) + , MemoryVoucherDatabase , memory ) where diff --git a/src/PaymentServer/Redemption.hs b/src/PaymentServer/Redemption.hs new file mode 100644 index 0000000000000000000000000000000000000000..23dfbc849b6699f61d1be229d85e3f7ae3535766 --- /dev/null +++ b/src/PaymentServer/Redemption.hs @@ -0,0 +1,144 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeOperators #-} +{-# LANGUAGE DeriveGeneric #-} + +-- | This module implements the exposed interface for redeeming a voucher for +-- signatures. +module PaymentServer.Redemption + ( RedemptionAPI + , BlindedToken + , Redeem(Redeem) + , Result(Failed, Succeeded) + , redemptionServer + ) where + +import GHC.Generics + ( Generic + ) +import Control.Monad.IO.Class + ( liftIO + ) +import Data.Text + ( Text + , pack + ) +import Data.Text.Encoding + ( encodeUtf8 + ) +import Data.Aeson + ( ToJSON(toJSON, toEncoding) + , FromJSON(parseJSON) + , withObject + , (.:) + , genericToEncoding + , defaultOptions + , encode + , object + , (.=) + ) +import Servant + ( Server + , Handler + , ServerError(errBody, errHeaders) + , err400 + , throwError + ) +import Servant.API + ( JSON + , Post + , ReqBody + , (:>) + ) +import Crypto.Hash + ( SHA3_512(SHA3_512) + , hashWith + ) +import PaymentServer.Persistence + ( VoucherDatabase(redeemVoucher) + , Fingerprint + , Voucher + ) + +-- | A cryptographic signature of a blinded token created using our private +-- key. +type Signature = Text + +-- | A public key corresponding to our private key. +type PublicKey = Text + +-- | A zero-knowledge proof that signatures were created of the corresponding +-- blinded tokens using the corresponding public key's private key. +type Proof = Text + +data Result + = Failed + | Succeeded PublicKey [Signature] Proof + deriving (Show, Eq) + +-- | A blinded token is presented along with a voucher to be signed and the +-- signatures returned to the caller. +type BlindedToken = Text + +-- | A complete redemption attempt which can be presented at the redemption +-- endpoint. +data Redeem + = Redeem + { redeemVoucher :: Voucher -- ^ The voucher being redeemed. + , redeemTokens :: [BlindedToken] -- ^ Tokens to be signed as part of this redemption. + } deriving (Show, Eq, Generic) + +instance FromJSON Redeem + +instance ToJSON Redeem where + toEncoding = genericToEncoding defaultOptions + +instance ToJSON Result where + toJSON Failed = object [ "success" .= False ] + toJSON (Succeeded key signatures proof) = object + [ "success" .= True + , "public-key" .= key + , "signatures" .= signatures + , "proof" .= proof + ] + +instance FromJSON Result where + parseJSON = withObject "Result" $ \v -> + v .: "success" >>= \success -> + if success then + Succeeded + <$> v .: "public-key" + <*> v .: "signatures" + <*> v .: "proof" + else + return Failed + +type RedemptionAPI = ReqBody '[JSON] Redeem :> Post '[JSON] Result + +jsonErr400 = err400 + { errBody = encode Failed + , errHeaders = [ ("Content-Type", "application/json;charset=utf-8") ] + } + +redemptionServer :: VoucherDatabase d => d -> Server RedemptionAPI +redemptionServer = redeem + +-- | Handler for redemption requests. Use the database to try to redeem the +-- voucher and return signatures. Return a failure if this is not possible +-- (eg because the voucher was already redeemed). +redeem :: VoucherDatabase d => d -> Redeem -> Handler Result +redeem database (Redeem voucher tokens) = do + let fingerprint = fingerprintFromTokens tokens + result <- liftIO $ PaymentServer.Persistence.redeemVoucher database voucher fingerprint + case result of + Left err -> throwError jsonErr400 + Right () -> return $ Succeeded "" [] "" + +-- | Compute a cryptographic hash (fingerprint) of a list of tokens which can +-- be used as an identifier for this exact sequence of tokens. +fingerprintFromTokens :: [BlindedToken] -> Fingerprint +fingerprintFromTokens = + let + hash = pack . show . hashWith SHA3_512 . encodeUtf8 + in + foldl (\b a -> hash $ a `mappend` b) "" . map hash diff --git a/src/PaymentServer/Server.hs b/src/PaymentServer/Server.hs index f4ecaf3eb1f62a307e9a8dbc9651b05b6447855e..f55b779b99f7a3395a02aca78a0f636e358f2542 100644 --- a/src/PaymentServer/Server.hs +++ b/src/PaymentServer/Server.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE TypeOperators #-} @@ -14,21 +13,30 @@ import Servant , Application , serve , (:>) + , (:<|>)((:<|>)) ) import PaymentServer.Processors.Stripe ( StripeAPI , stripeServer ) +import PaymentServer.Redemption + ( RedemptionAPI + , redemptionServer + ) import PaymentServer.Persistence ( VoucherDatabase ) -- | This is the complete type of the server API. -type PaymentServerAPI = "v1" :> "stripe" :> StripeAPI +type PaymentServerAPI + = "v1" :> "stripe" :> StripeAPI + :<|> "v1" :> "redeem" :> RedemptionAPI -- | Create a server which uses the given database. paymentServer :: VoucherDatabase d => d -> Server PaymentServerAPI -paymentServer = stripeServer +paymentServer d = + stripeServer d + :<|> redemptionServer d paymentServerAPI :: Proxy PaymentServerAPI paymentServerAPI = Proxy @@ -36,4 +44,4 @@ paymentServerAPI = Proxy -- | Create a Servant Application which serves the payment server API using -- the given database. paymentServerApp :: VoucherDatabase d => d -> Application -paymentServerApp = (serve paymentServerAPI) . paymentServer +paymentServerApp = serve paymentServerAPI . paymentServer diff --git a/test/SpecRedemption.hs b/test/SpecRedemption.hs new file mode 100644 index 0000000000000000000000000000000000000000..e9c05108d596bfc232cb7cf749026960587212db --- /dev/null +++ b/test/SpecRedemption.hs @@ -0,0 +1,176 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +-- | Test suite related to voucher redemption. + +module SpecRedemption where + +import Data.ByteString + ( ByteString + ) +import Text.Printf + ( printf + ) +import Data.Aeson + ( decode + , encode + ) +import Servant + ( Application + , Proxy(Proxy) + , serve + ) +import Test.Hspec + ( Spec + , parallel + , describe + , it + , runIO + ) +import Network.HTTP.Types + ( Header + ) +import Test.Hspec.Wai + ( ResponseMatcher(matchBody, matchHeaders) + , (<:>) + , WaiExpectation + , Body + , MatchBody(MatchBody) + , with + , shouldRespondWith + , liftIO + ) +import Test.Hspec.Wai.QuickCheck + ( property + ) +import Test.QuickCheck.Instances.Text () +import Util.Spec + ( wrongMethodNotAllowed + , nonJSONUnsupportedMediaType + , wrongJSONInvalidRequest + ) +import Util.WAI + ( postJSON + ) +import PaymentServer.Redemption + ( RedemptionAPI + , BlindedToken + , Redeem(Redeem) + , Result(Failed, Succeeded) + , redemptionServer + ) +import PaymentServer.Persistence + ( RedeemError(NotPaid) + , Voucher + , VoucherDatabase(payForVoucher, redeemVoucher) + , memory + ) + +redemptionAPI :: Proxy RedemptionAPI +redemptionAPI = Proxy + +app :: VoucherDatabase d => d -> Application +app = serve redemptionAPI . redemptionServer + +path = "/" + +propertyRedeem :: ByteString -> Voucher -> [BlindedToken] -> ResponseMatcher -> WaiExpectation +propertyRedeem path voucher tokens matcher = + postJSON path (encode $ Redeem voucher tokens) `shouldRespondWith` matcher + +-- | A VoucherDatabaseTestDouble has a VoucherDatabase instance which provides +-- a number of different behaviors which are useful to be able to directly +-- test against. +data VoucherDatabaseTestDouble + -- | A RefuseRedemption database always refuses redemption with a given error. + = RefuseRedemption RedeemError + -- | A PermitRedemption database always permits redemption. + | PermitRedemption + deriving (Show) + +instance VoucherDatabase VoucherDatabaseTestDouble where + payForVoucher _ voucher = return () + redeemVoucher (RefuseRedemption err) _ _ = return $ Left err + redeemVoucher PermitRedemption _ _ = return $ Right () + +spec_redemption :: Spec +spec_redemption = parallel $ do + database <- runIO memory + with (return . app $ database) $ + do + describe (printf "error behavior of POST %s" (show path)) $ + do + wrongMethodNotAllowed "GET" path + nonJSONUnsupportedMediaType path + wrongJSONInvalidRequest path "{}" + + -- I would rather write these two as property tests but I don't know + -- how. + describe "double redemption" $ do + it "succeeds with the same tokens" $ do + let voucher = "abc" :: Voucher + let tokens = [ "def", "ghi" ] :: [BlindedToken] + liftIO $ payForVoucher database voucher + propertyRedeem path voucher tokens 200 + propertyRedeem path voucher tokens 200 + + it "fails with different tokens" $ do + let voucher = "jkl" :: Voucher + let firstTokens = [ "mno", "pqr" ] :: [BlindedToken] + let secondTokens = [ "stu", "vwx" ] :: [BlindedToken] + liftIO $ payForVoucher database voucher + propertyRedeem path voucher firstTokens 200 + propertyRedeem path voucher secondTokens 400 + + + describe "redemption" $ do + with (return . app $ RefuseRedemption NotPaid) $ + it "receives a failure response when the voucher is not paid" $ property $ + \(voucher :: Voucher) (tokens :: [BlindedToken]) -> + propertyRedeem path voucher tokens 400 + { matchBody = matchJSONBody Failed + -- major/minor, fine. charset=utf-8... okay. but really this is + -- overspecified by encoding the exact byte sequence. I'd rather + -- assert semantic equality. + , matchHeaders = ["Content-Type" <:> "application/json;charset=utf-8"] + } + + with (return $ app PermitRedemption) $ + it "receive a success response when redemption succeeds" $ property $ + \(voucher :: Voucher) (tokens :: [BlindedToken]) -> + propertyRedeem path voucher tokens 200 + -- TODO: Get some real crypto involved to be able to replace these + -- dummy values. + { matchBody = matchJSONBody $ Succeeded "" [] "" + , matchHeaders = ["Content-Type" <:> "application/json;charset=utf-8"] + } + + -- it "receive 200 (OK) when the voucher is paid and previously redeemed with the same tokens" $ + -- property $ \(voucher :: Voucher) (tokens :: [BlindedToken]) -> + -- do + -- liftIO $ payForVoucher database voucher + -- postJSON path (encode $ Redeem voucher tokens) `shouldRespondWith` 200 + -- postJSON path (encode $ Redeem voucher tokens) `shouldRespondWith` 200 + + -- it "receive 400 (OK) when the voucher is paid and previously redeemed with different tokens" $ + -- property $ \(voucher :: Voucher) (firstTokens :: [BlindedToken]) (secondTokens :: [BlindedToken]) -> + -- do + -- liftIO $ payForVoucher database voucher + -- postJSON path (encode $ Redeem voucher firstTokens) `shouldRespondWith` 200 + -- postJSON path (encode $ Redeem voucher secondTokens) `shouldRespondWith` 400 + +matchJSONBody :: Result -> MatchBody +matchJSONBody expected = + let + bodyMatcher :: [Header] -> Body -> Maybe String + bodyMatcher headers actualBody = + case decode actualBody of + Nothing -> + Just $ "failed to decode body as value of expected type: " ++ show actualBody + Just actual -> + if actual == expected then + Nothing + else + Just $ "decoded body does not equal expected value: " ++ show actual ++ show expected + in + MatchBody bodyMatcher diff --git a/test/SpecStripe.hs b/test/SpecStripe.hs index 23ad2932d4368416880b5383a23f9d947017eea8..d2a281b1f5eadef91c4fee31e2f73b387f11e17a 100644 --- a/test/SpecStripe.hs +++ b/test/SpecStripe.hs @@ -14,6 +14,7 @@ import Data.Aeson ) import Test.Hspec ( Spec + , parallel , describe , it ) @@ -72,6 +73,11 @@ import PaymentServer.Persistence ( Voucher , memory ) +import Util.Spec + ( wrongMethodNotAllowed + , nonJSONUnsupportedMediaType + , wrongJSONInvalidRequest + ) stripeAPI :: Proxy StripeAPI stripeAPI = Proxy @@ -80,13 +86,11 @@ app :: IO Application app = serve stripeAPI . stripeServer <$> memory spec_webhook :: Spec -spec_webhook = with app $ do +spec_webhook = with app $ parallel $ do describe "error behavior of POST /webhook" $ do - it "responds to non-JSON Content-Type with 415 (Unsupported Media Type)" $ - post "/webhook" "xxx" `shouldRespondWith` 415 - - it "responds to JSON non-Event body with 400 (Invalid Request)" $ - postJSON "/webhook" "{}" `shouldRespondWith` 400 + wrongMethodNotAllowed "GET" "/webhook" + nonJSONUnsupportedMediaType "/webhook" + wrongJSONInvalidRequest "/webhook" "{}" -- I would like to make most or all of these into property tests. *This* -- test shows how you can do it. Yay. The main thing (for me, anyway) to @@ -109,7 +113,6 @@ spec_webhook = with app $ do property xtest_postWithEventBody - bodyMatcher :: [Network.HTTP.Types.Header] -> Body -> Maybe String bodyMatcher _ "{}" = Nothing bodyMatcher _ body = Just $ show body diff --git a/test/Util/Spec.hs b/test/Util/Spec.hs new file mode 100644 index 0000000000000000000000000000000000000000..9ce6f8de4f85163cc5e050c03511d95aac801083 --- /dev/null +++ b/test/Util/Spec.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Util.Spec + ( wrongMethodNotAllowed + , nonJSONUnsupportedMediaType + , wrongJSONInvalidRequest + ) where + +import Test.Hspec + ( it + ) +import Test.Hspec.Wai + ( post + , request + , shouldRespondWith + ) + +import Util.WAI + ( postJSON + ) + +wrongMethodNotAllowed method path = + it "responds to an unsupported method with 405 (Method Not Allowed)" $ + request method path [] "" `shouldRespondWith` 405 + +nonJSONUnsupportedMediaType path = + it "responds to non-JSON Content-Type with 415 (Unsupported Media Type)" $ + post path "xxx" `shouldRespondWith` 415 + +wrongJSONInvalidRequest path json = + it "responds to JSON body representing the wrong data with 400 (Invalid Request)" $ + postJSON path json `shouldRespondWith` 400