mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-24 19:13:29 +00:00
185 lines
8.8 KiB
Haskell
185 lines
8.8 KiB
Haskell
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
module Bots.BadgeService.FakePlay
|
|
( FakePlay (..),
|
|
PlayAnswer (..),
|
|
withFakePlay,
|
|
answerPurchase,
|
|
answerAcknowledgements,
|
|
answerTokenRequests,
|
|
grantTokensFor,
|
|
fakePackageName,
|
|
)
|
|
where
|
|
|
|
import BadgeService.Config (PlayStoreConfig (..))
|
|
import Control.Concurrent (threadDelay)
|
|
import Control.Concurrent.STM
|
|
import Control.Monad (forever)
|
|
import Crypto.Hash.Algorithms (SHA256 (..))
|
|
import Crypto.Number.Serialize (i2osp)
|
|
import qualified Crypto.PubKey.RSA as RSA
|
|
import qualified Crypto.PubKey.RSA.PKCS15 as RSA
|
|
import qualified Data.Aeson as J
|
|
import qualified Data.Aeson.KeyMap as KM
|
|
import qualified Data.ByteString as B
|
|
import qualified Data.ByteString.Base64 as B64
|
|
import qualified Data.ByteString.Base64.URL as B64U
|
|
import qualified Data.ByteString.Char8 as B8
|
|
import qualified Data.ByteString.Lazy as LB
|
|
import qualified Data.Map.Strict as M
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (decodeUtf8)
|
|
import Network.HTTP.Types (Status, badRequest400, hAuthorization, hContentType, methodGet, methodPost, mkStatus, notFound404, ok200, parseSimpleQuery, unauthorized401)
|
|
import Network.Wai (Application, Response, pathInfo, requestHeaders, requestMethod, responseLBS, strictRequestBody)
|
|
import qualified Network.Wai.Handler.Warp as Warp
|
|
import System.Directory (createDirectoryIfMissing)
|
|
import System.FilePath ((</>))
|
|
import UnliftIO.Temporary (withTempDirectory)
|
|
|
|
data PlayAnswer = PlayRecord J.Value | PlayBody LB.ByteString | PlayStatus Int | PlayHang
|
|
|
|
data FakePlay = FakePlay
|
|
{ fpConfig :: PlayStoreConfig,
|
|
fpAnswers :: TVar (M.Map Text PlayAnswer),
|
|
-- a status the token endpoint answers instead of granting
|
|
fpTokenRefusal :: TVar (Maybe Int),
|
|
fpTokenSeconds :: TVar Int,
|
|
fpGrantedTokens :: TVar [B.ByteString],
|
|
fpPurchasePaths :: TVar [[Text]],
|
|
-- what the acknowledge endpoint answers instead of acknowledging
|
|
fpAcknowledgeRefusal :: TVar (Maybe PlayAnswer),
|
|
fpAcknowledged :: TVar [Text]
|
|
}
|
|
|
|
fakePackageName :: Text
|
|
fakePackageName = "chat.simplex.app"
|
|
|
|
fakeClientEmail :: Text
|
|
fakeClientEmail = "badge-verifier@simplex-badges-test.iam.gserviceaccount.com"
|
|
|
|
-- | Warp chooses the port, so a server left over from an earlier run cannot answer instead.
|
|
-- The service account key is made per run, since a committed one trips secret scanning.
|
|
withFakePlay :: (FakePlay -> IO a) -> IO a
|
|
withFakePlay action = do
|
|
(key, privateKey) <- RSA.generate 128 65537
|
|
fpAnswers <- newTVarIO M.empty
|
|
fpTokenRefusal <- newTVarIO Nothing
|
|
fpTokenSeconds <- newTVarIO 3600
|
|
fpGrantedTokens <- newTVarIO []
|
|
fpPurchasePaths <- newTVarIO []
|
|
fpAcknowledgeRefusal <- newTVarIO Nothing
|
|
fpAcknowledged <- newTVarIO []
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "fake-play" $ \d -> do
|
|
let accountFile = d </> "service-account.json"
|
|
fake base =
|
|
FakePlay
|
|
{ fpConfig = PlayStoreConfig {gPackageName = fakePackageName, gServiceAccountFile = accountFile, gApiHost = base, gTokenUrl = base <> "/token"},
|
|
fpAnswers,
|
|
fpTokenRefusal,
|
|
fpTokenSeconds,
|
|
fpGrantedTokens,
|
|
fpPurchasePaths,
|
|
fpAcknowledgeRefusal,
|
|
fpAcknowledged
|
|
}
|
|
LB.writeFile accountFile $ J.encode $ J.object ["client_email" J..= fakeClientEmail, "private_key" J..= decodeUtf8 (pkcs8Pem privateKey)]
|
|
Warp.testWithApplication (pure $ \req respond -> fakeApp key (fake $ origin req) req respond) $ \prt ->
|
|
action (fake $ "http://127.0.0.1:" <> T.pack (show prt))
|
|
where
|
|
-- the app needs the base URL for the audience it checks, and the request names the port it reached
|
|
origin req = maybe "" (("http://" <>) . T.pack . B8.unpack) (lookup "Host" (requestHeaders req))
|
|
|
|
-- | The PKCS#8 PEM Google puts in a service account key, so the service's own parser reads it.
|
|
pkcs8Pem :: RSA.PrivateKey -> B.ByteString
|
|
pkcs8Pem k =
|
|
"-----BEGIN PRIVATE KEY-----\n" <> B8.unlines (chunks $ B64.encode pkcs8) <> "-----END PRIVATE KEY-----\n"
|
|
where
|
|
RSA.PublicKey {RSA.public_n, RSA.public_e} = RSA.private_pub k
|
|
pkcs8 = der 0x30 $ derInteger 0 <> der 0x30 (rsaEncryption <> "\x05\x00") <> der 0x04 pkcs1
|
|
pkcs1 = der 0x30 $ foldMap derInteger [0, public_n, public_e, RSA.private_d k, RSA.private_p k, RSA.private_q k, RSA.private_dP k, RSA.private_dQ k, RSA.private_qinv k]
|
|
rsaEncryption = der 0x06 "\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01"
|
|
derInteger n = der 0x02 $ case i2osp n of
|
|
bs | B.null bs || B.head bs >= 0x80 -> B.cons 0 bs
|
|
bs -> bs
|
|
der tag content = B.pack (tag : derLength (B.length content)) <> content
|
|
derLength n
|
|
| n < 0x80 = [fromIntegral n]
|
|
| otherwise = let bs = B.unpack (i2osp $ toInteger n) in 0x80 + fromIntegral (length bs) : bs
|
|
chunks bs
|
|
| B.null bs = []
|
|
| otherwise = let (line, rest) = B.splitAt 64 bs in line : chunks rest
|
|
|
|
answerPurchase :: FakePlay -> Text -> PlayAnswer -> IO ()
|
|
answerPurchase FakePlay {fpAnswers} token answer = atomically $ modifyTVar' fpAnswers $ M.insert token answer
|
|
|
|
answerTokenRequests :: FakePlay -> Maybe Int -> IO ()
|
|
answerTokenRequests FakePlay {fpTokenRefusal} = atomically . writeTVar fpTokenRefusal
|
|
|
|
grantTokensFor :: FakePlay -> Int -> IO ()
|
|
grantTokensFor FakePlay {fpTokenSeconds} = atomically . writeTVar fpTokenSeconds
|
|
|
|
answerAcknowledgements :: FakePlay -> Maybe PlayAnswer -> IO ()
|
|
answerAcknowledgements FakePlay {fpAcknowledgeRefusal} = atomically . writeTVar fpAcknowledgeRefusal
|
|
|
|
fakeApp :: RSA.PublicKey -> FakePlay -> Application
|
|
fakeApp key FakePlay {fpConfig = PlayStoreConfig {gTokenUrl}, fpAnswers, fpTokenRefusal, fpTokenSeconds, fpGrantedTokens, fpPurchasePaths, fpAcknowledgeRefusal, fpAcknowledged} req respond =
|
|
case (requestMethod req, pathInfo req) of
|
|
(m, ["token"]) | m == methodPost -> strictRequestBody req >>= grant
|
|
(m, path@["androidpublisher", "v3", "applications", pkg, "purchases", "products", _, "tokens", token])
|
|
| m == methodGet && pkg == fakePackageName -> authorized $ do
|
|
atomically $ modifyTVar' fpPurchasePaths (<> [path])
|
|
purchase token
|
|
| m == methodPost && pkg == fakePackageName, Just acknowledgedToken <- T.stripSuffix ":acknowledge" token -> authorized $ acknowledge acknowledgedToken
|
|
_ -> respond $ status notFound404
|
|
where
|
|
authorized act = do
|
|
granted <- readTVarIO fpGrantedTokens
|
|
case lookup hAuthorization (requestHeaders req) of
|
|
Just auth | Just bearer <- B.stripPrefix "Bearer " auth, take 1 (reverse granted) == [bearer] -> act
|
|
_ -> respond $ status unauthorized401
|
|
acknowledge token =
|
|
readTVarIO fpAcknowledgeRefusal >>= \case
|
|
Just answer -> answerWith answer
|
|
Nothing -> do
|
|
atomically $ modifyTVar' fpAcknowledged (<> [token])
|
|
respond $ responseLBS ok200 [] ""
|
|
grant body =
|
|
readTVarIO fpTokenRefusal >>= \case
|
|
Just code -> respond $ status (mkStatus code "Refused")
|
|
Nothing
|
|
| validAssertion (parseSimpleQuery $ LB.toStrict body) -> do
|
|
bearer <- atomically $ stateTVar fpGrantedTokens $ \ts -> let t = "fake-access-" <> B8.pack (show (length ts)) in (t, ts <> [t])
|
|
seconds <- readTVarIO fpTokenSeconds
|
|
respond $ json ok200 $ J.object ["access_token" J..= B8.unpack bearer, "expires_in" J..= seconds, "token_type" J..= ("Bearer" :: Text)]
|
|
| otherwise -> respond $ json badRequest400 $ J.object ["error" J..= ("invalid_grant" :: Text)]
|
|
validAssertion form = case (lookup "grant_type" form, B8.split '.' <$> lookup "assertion" form) of
|
|
(Just "urn:ietf:params:oauth:grant-type:jwt-bearer", Just [header, claims, sig])
|
|
| Right sigBytes <- B64U.decodeUnpadded sig,
|
|
RSA.verify (Just SHA256) key (header <> "." <> claims) sigBytes,
|
|
Right claimBytes <- B64U.decodeUnpadded claims,
|
|
Just (J.Object c) <- J.decodeStrict' claimBytes ->
|
|
KM.lookup "aud" c == Just (J.String gTokenUrl)
|
|
&& KM.lookup "scope" c == Just (J.String "https://www.googleapis.com/auth/androidpublisher")
|
|
&& KM.lookup "iss" c == Just (J.String fakeClientEmail)
|
|
_ -> False
|
|
purchase token =
|
|
M.lookup token <$> readTVarIO fpAnswers >>= \case
|
|
Just answer -> answerWith answer
|
|
Nothing -> respond $ status notFound404
|
|
answerWith = \case
|
|
PlayRecord v -> respond $ json ok200 v
|
|
PlayBody b -> respond $ responseLBS ok200 [(hContentType, "application/json")] b
|
|
PlayStatus code -> respond $ status (mkStatus code "Answer")
|
|
PlayHang -> forever $ threadDelay 1000000
|
|
|
|
json :: Status -> J.Value -> Response
|
|
json st = responseLBS st [(hContentType, "application/json")] . J.encode
|
|
|
|
status :: Status -> Response
|
|
status st = json st $ J.object ["error" J..= J.object ["code" J..= show st]]
|