mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-05-11 16:54:45 +00:00
45ddecc4b8
* APN push client (WIP) * APNS push client works * TODO comments * comment * send notification and process the response * config * e2e encryption for notification parameter * read apns key filename and ID from env * connect APNS client to server, fix notification JSON encoding to use dash case * do not connect to APNS on start to pass CI tests * fix CI test * remove redundant import
349 lines
14 KiB
Haskell
349 lines
14 KiB
Haskell
{-# LANGUAGE DeriveGeneric #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
module Simplex.Messaging.Notifications.Server.Push.APNS where
|
|
|
|
import Control.Monad.Except
|
|
import Crypto.Hash.Algorithms (SHA256 (..))
|
|
import qualified Crypto.PubKey.ECC.ECDSA as EC
|
|
import qualified Crypto.PubKey.ECC.Types as ECT
|
|
import Crypto.Random (ChaChaDRG, drgNew)
|
|
import qualified Crypto.Store.PKCS8 as PK
|
|
import Data.ASN1.BinaryEncoding (DER (..))
|
|
import Data.ASN1.Encoding
|
|
import Data.ASN1.Types
|
|
import Data.Aeson (FromJSON, ToJSON, (.=))
|
|
import qualified Data.Aeson as J
|
|
import qualified Data.Aeson.Encoding as JE
|
|
import Data.Bifunctor (first)
|
|
import qualified Data.ByteString.Base64.URL as U
|
|
import Data.ByteString.Builder (lazyByteString)
|
|
import Data.ByteString.Char8 (ByteString)
|
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
|
import qualified Data.CaseInsensitive as CI
|
|
import Data.Int (Int64)
|
|
import Data.Map.Strict (Map)
|
|
import Data.Maybe (fromMaybe)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (decodeUtf8With)
|
|
import Data.Time.Clock.System
|
|
import qualified Data.X509 as X
|
|
import GHC.Generics
|
|
import Network.HTTP.Types (HeaderName, Status, hAuthorization, methodPost)
|
|
import qualified Network.HTTP.Types as N
|
|
import Network.HTTP2.Client (Request)
|
|
import qualified Network.HTTP2.Client as H
|
|
import Network.Socket (HostName, ServiceName)
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import Simplex.Messaging.Encoding.String
|
|
import Simplex.Messaging.Notifications.Protocol
|
|
import Simplex.Messaging.Notifications.Server.Subscriptions (NtfTknData (..))
|
|
import Simplex.Messaging.Protocol (NotifierId, SMPServer)
|
|
import Simplex.Messaging.Transport.Client.HTTP2
|
|
import System.Environment (getEnv)
|
|
import UnliftIO.STM
|
|
|
|
data JWTHeader = JWTHeader
|
|
{ alg :: Text, -- key algorithm, ES256 for APNS
|
|
kid :: Text -- key ID
|
|
}
|
|
deriving (Show, Generic)
|
|
|
|
instance ToJSON JWTHeader where toEncoding = J.genericToEncoding J.defaultOptions
|
|
|
|
data JWTClaims = JWTClaims
|
|
{ iss :: Text, -- issuer, team ID for APNS
|
|
iat :: Int64 -- issue time, seconds from epoch
|
|
}
|
|
deriving (Show, Generic)
|
|
|
|
instance ToJSON JWTClaims where toEncoding = J.genericToEncoding J.defaultOptions
|
|
|
|
data JWTToken = JWTToken JWTHeader JWTClaims
|
|
deriving (Show)
|
|
|
|
mkJWTToken :: JWTHeader -> Text -> IO JWTToken
|
|
mkJWTToken hdr iss = do
|
|
iat <- systemSeconds <$> getSystemTime
|
|
pure $ JWTToken hdr JWTClaims {iss, iat}
|
|
|
|
type SignedJWTToken = ByteString
|
|
|
|
signedJWTToken :: EC.PrivateKey -> JWTToken -> IO SignedJWTToken
|
|
signedJWTToken pk (JWTToken hdr claims) = do
|
|
let hc = jwtEncode hdr <> "." <> jwtEncode claims
|
|
sig <- EC.sign pk SHA256 hc
|
|
pure $ hc <> "." <> serialize sig
|
|
where
|
|
jwtEncode :: ToJSON a => a -> ByteString
|
|
jwtEncode = U.encodeUnpadded . LB.toStrict . J.encode
|
|
serialize sig = U.encodeUnpadded $ encodeASN1' DER [Start Sequence, IntVal (EC.sign_r sig), IntVal (EC.sign_s sig), End Sequence]
|
|
|
|
readECPrivateKey :: FilePath -> IO EC.PrivateKey
|
|
readECPrivateKey f = do
|
|
-- TODO this is specific to APNS key
|
|
[PK.Unprotected (X.PrivKeyEC X.PrivKeyEC_Named {privkeyEC_name, privkeyEC_priv})] <- PK.readKeyFile f
|
|
pure EC.PrivateKey {private_curve = ECT.getCurveByName privkeyEC_name, private_d = privkeyEC_priv}
|
|
|
|
data PushNotification
|
|
= PNVerification NtfRegCode
|
|
| PNMessage SMPServer NotifierId
|
|
| PNAlert Text
|
|
| PNCheckMessages
|
|
|
|
data APNSNotification = APNSNotification {aps :: APNSNotificationBody, notificationData :: Maybe J.Value}
|
|
deriving (Show, Generic)
|
|
|
|
instance ToJSON APNSNotification where
|
|
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
|
|
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
|
|
|
data APNSNotificationBody
|
|
= APNSAlert {alert :: APNSAlertBody, badge :: Maybe Int, sound :: Maybe Text, category :: Maybe Text}
|
|
| APNSBackground {contentAvailable :: Int}
|
|
| APNSMutableContent {mutableContent :: Int, alert :: APNSAlertBody, category :: Maybe Text}
|
|
deriving (Show, Generic)
|
|
|
|
apnsJSONOptions :: J.Options
|
|
apnsJSONOptions = J.defaultOptions {J.omitNothingFields = True, J.sumEncoding = J.UntaggedValue, J.fieldLabelModifier = J.camelTo2 '-'}
|
|
|
|
instance ToJSON APNSNotificationBody where
|
|
toJSON = J.genericToJSON apnsJSONOptions
|
|
toEncoding = J.genericToEncoding apnsJSONOptions
|
|
|
|
type APNSNotificationData = Map Text Text
|
|
|
|
data APNSAlertBody = APNSAlertObject {title :: Text, subtitle :: Text, body :: Text} | APNSAlertText Text
|
|
deriving (Show)
|
|
|
|
instance ToJSON APNSAlertBody where
|
|
toEncoding = \case
|
|
APNSAlertObject {title, subtitle, body} -> J.pairs $ "title" .= title <> "subtitle" .= subtitle <> "body" .= body
|
|
APNSAlertText t -> JE.text t
|
|
toJSON = \case
|
|
APNSAlertObject {title, subtitle, body} -> J.object ["title" .= title, "subtitle" .= subtitle, "body" .= body]
|
|
APNSAlertText t -> J.String t
|
|
|
|
-- APNS notification types
|
|
--
|
|
-- Visible alerts:
|
|
-- {
|
|
-- "aps" : {
|
|
-- "alert" : {
|
|
-- "title" : "Game Request",
|
|
-- "subtitle" : "Five Card Draw",
|
|
-- "body" : "Bob wants to play poker"
|
|
-- },
|
|
-- "badge" : 9,
|
|
-- "sound" : "bingbong.aiff",
|
|
-- "category" : "GAME_INVITATION"
|
|
-- },
|
|
-- "gameID" : "12345678"
|
|
-- }
|
|
--
|
|
-- Simple text alert:
|
|
-- {"aps":{"alert":"you have a new message"}}
|
|
--
|
|
-- Background notification to fetch content
|
|
-- {"aps":{"content-available":1}}
|
|
--
|
|
-- Mutable content notification that must be shown but can be processed before before being shown (up to 30 sec)
|
|
-- {
|
|
-- "aps" : {
|
|
-- "category" : "SECRET",
|
|
-- "mutable-content" : 1,
|
|
-- "alert" : {
|
|
-- "title" : "Secret Message!",
|
|
-- "body" : "(Encrypted)"
|
|
-- },
|
|
-- },
|
|
-- "ENCRYPTED_DATA" : "Salted__·öîQÊ$UDì_¶Ù∞èΩ^¬%gq∞NÿÒQùw"
|
|
-- }
|
|
|
|
data APNSPushClientConfig = APNSPushClientConfig
|
|
{ tokenTTL :: Int64,
|
|
authKeyFileEnv :: String,
|
|
authKeyAlg :: Text,
|
|
authKeyIdEnv :: String,
|
|
paddedNtfLength :: Int,
|
|
appName :: ByteString,
|
|
appTeamId :: Text,
|
|
apnHost :: HostName,
|
|
apnPort :: ServiceName,
|
|
https2cfg :: HTTP2SClientConfig
|
|
}
|
|
deriving (Show)
|
|
|
|
defaultAPNSPushClientConfig :: APNSPushClientConfig
|
|
defaultAPNSPushClientConfig =
|
|
APNSPushClientConfig
|
|
{ tokenTTL = 1200, -- 20 minutes
|
|
authKeyFileEnv = "APNS_KEY_FILE", -- the environment variables APNS_KEY_FILE and APNS_KEY_ID must be set, or the server would fail to start
|
|
authKeyAlg = "ES256",
|
|
authKeyIdEnv = "APNS_KEY_ID",
|
|
paddedNtfLength = 256,
|
|
appName = "chat.simplex.app",
|
|
appTeamId = "5NN7GUYB6T",
|
|
apnHost = "api.sandbox.push.apple.com",
|
|
apnPort = "443",
|
|
https2cfg = defaultHTTP2SClientConfig
|
|
}
|
|
|
|
data APNSPushClient = APNSPushClient
|
|
{ https2Client :: TVar (Maybe HTTPS2Client),
|
|
privateKey :: EC.PrivateKey,
|
|
jwtHeader :: JWTHeader,
|
|
jwtToken :: TVar (JWTToken, SignedJWTToken),
|
|
nonceDrg :: TVar ChaChaDRG,
|
|
apnsCfg :: APNSPushClientConfig
|
|
}
|
|
|
|
createAPNSPushClient :: APNSPushClientConfig -> IO APNSPushClient
|
|
createAPNSPushClient apnsCfg@APNSPushClientConfig {authKeyFileEnv, authKeyAlg, authKeyIdEnv, appTeamId} = do
|
|
https2Client <- newTVarIO Nothing
|
|
void $ connectHTTPS2 apnsCfg https2Client
|
|
privateKey <- readECPrivateKey =<< getEnv authKeyFileEnv
|
|
authKeyId <- T.pack <$> getEnv authKeyIdEnv
|
|
putStrLn $ authKeyIdEnv <> "=" <> T.unpack authKeyId
|
|
let jwtHeader = JWTHeader {alg = authKeyAlg, kid = authKeyId}
|
|
jwtToken <- newTVarIO =<< mkApnsJWTToken appTeamId jwtHeader privateKey
|
|
nonceDrg <- drgNew >>= newTVarIO
|
|
pure APNSPushClient {https2Client, privateKey, jwtHeader, jwtToken, nonceDrg, apnsCfg}
|
|
|
|
getApnsJWTToken :: APNSPushClient -> IO SignedJWTToken
|
|
getApnsJWTToken APNSPushClient {apnsCfg = APNSPushClientConfig {appTeamId, tokenTTL}, privateKey, jwtHeader, jwtToken} = do
|
|
(jwt, signedJWT) <- readTVarIO jwtToken
|
|
age <- jwtTokenAge jwt
|
|
if age < tokenTTL
|
|
then pure signedJWT
|
|
else do
|
|
t@(_, signedJWT') <- mkApnsJWTToken appTeamId jwtHeader privateKey
|
|
atomically $ writeTVar jwtToken t
|
|
pure signedJWT'
|
|
where
|
|
jwtTokenAge (JWTToken _ JWTClaims {iat}) = (iat -) . systemSeconds <$> getSystemTime
|
|
|
|
mkApnsJWTToken :: Text -> JWTHeader -> EC.PrivateKey -> IO (JWTToken, SignedJWTToken)
|
|
mkApnsJWTToken appTeamId jwtHeader privateKey = do
|
|
jwt <- mkJWTToken jwtHeader appTeamId
|
|
signedJWT <- signedJWTToken privateKey jwt
|
|
pure (jwt, signedJWT)
|
|
|
|
connectHTTPS2 :: APNSPushClientConfig -> TVar (Maybe HTTPS2Client) -> IO (Either HTTPS2ClientError HTTPS2Client)
|
|
connectHTTPS2 APNSPushClientConfig {apnHost, apnPort, https2cfg} https2Client = do
|
|
r <- getHTTPS2Client apnHost apnPort https2cfg disconnected
|
|
case r of
|
|
Right client -> atomically . writeTVar https2Client $ Just client
|
|
Left e -> putStrLn $ "Error connecting to APNS: " <> show e
|
|
pure r
|
|
where
|
|
disconnected = atomically $ writeTVar https2Client Nothing
|
|
|
|
getApnsHTTP2Client :: APNSPushClient -> IO (Either HTTPS2ClientError HTTPS2Client)
|
|
getApnsHTTP2Client APNSPushClient {https2Client, apnsCfg} =
|
|
readTVarIO https2Client >>= maybe (connectHTTPS2 apnsCfg https2Client) (pure . Right)
|
|
|
|
disconnectApnsHTTP2Client :: APNSPushClient -> IO ()
|
|
disconnectApnsHTTP2Client APNSPushClient {https2Client} =
|
|
readTVarIO https2Client >>= mapM_ closeHTTPS2Client >> atomically (writeTVar https2Client Nothing)
|
|
|
|
apnsNotification :: NtfTknData -> C.CbNonce -> Int -> PushNotification -> Either C.CryptoError APNSNotification
|
|
apnsNotification NtfTknData {tknDhSecret} nonce paddedLen = \case
|
|
PNVerification (NtfRegCode code) ->
|
|
encrypt code $ \code' ->
|
|
apn APNSBackground {contentAvailable = 1} . Just $ J.object ["verification" .= code']
|
|
PNMessage srv nId ->
|
|
encrypt (strEncode srv <> "/" <> strEncode nId) $ \ntfQueue ->
|
|
apn apnMutableContent . Just $ J.object ["checkMessage" .= ntfQueue]
|
|
PNAlert text -> Right $ apn (apnAlert $ APNSAlertText text) Nothing
|
|
PNCheckMessages -> Right $ apn APNSBackground {contentAvailable = 1} . Just $ J.object ["checkMessages" .= True]
|
|
where
|
|
encrypt :: ByteString -> (Text -> APNSNotification) -> Either C.CryptoError APNSNotification
|
|
encrypt ntfData f = f . safeDecodeUtf8 . U.encode <$> C.cbEncrypt tknDhSecret nonce ntfData paddedLen
|
|
apn aps notificationData = APNSNotification {aps, notificationData}
|
|
apnMutableContent = APNSMutableContent {mutableContent = 1, alert = APNSAlertText "Encrypted message or some other app event", category = Nothing}
|
|
apnAlert alert = APNSAlert {alert, badge = Nothing, sound = Nothing, category = Nothing}
|
|
safeDecodeUtf8 = decodeUtf8With onError where onError _ _ = Just '?'
|
|
|
|
apnsRequest :: APNSPushClient -> ByteString -> APNSNotification -> IO Request
|
|
apnsRequest c tkn ntf@APNSNotification {aps} = do
|
|
signedJWT <- getApnsJWTToken c
|
|
pure $ H.requestBuilder methodPost path (headers signedJWT) (lazyByteString $ J.encode ntf)
|
|
where
|
|
path = "/3/device/" <> tkn
|
|
headers signedJWT =
|
|
[ (hApnsTopic, appName $ apnsCfg (c :: APNSPushClient)),
|
|
(hApnsPushType, pushType aps),
|
|
(hAuthorization, "bearer " <> signedJWT)
|
|
]
|
|
<> [(hApnsPriority, "5") | isBackground aps]
|
|
isBackground = \case
|
|
APNSBackground {} -> True
|
|
_ -> False
|
|
pushType = \case
|
|
APNSBackground {} -> "background"
|
|
_ -> "alert"
|
|
|
|
data PushProviderError
|
|
= PPConnection HTTPS2ClientError
|
|
| PPCryptoError C.CryptoError
|
|
| PPResponseError (Maybe Status) Text
|
|
| PPTokenInvalid
|
|
| PPRetryLater
|
|
| PPPermanentError
|
|
deriving (Show)
|
|
|
|
type PushProviderClient = NtfTknData -> PushNotification -> ExceptT PushProviderError IO ()
|
|
|
|
newtype APNSErrorReponse = APNSErrorReponse {reason :: Text}
|
|
deriving (Generic, FromJSON)
|
|
|
|
apnsPushProviderClient :: APNSPushClient -> PushProviderClient
|
|
apnsPushProviderClient c@APNSPushClient {nonceDrg, apnsCfg} tkn@NtfTknData {token = DeviceToken PPApple tknStr} pn = do
|
|
http2 <- liftHTTPS2 $ getApnsHTTP2Client c
|
|
nonce <- atomically $ C.pseudoRandomCbNonce nonceDrg
|
|
apnsNtf <- liftEither $ first PPCryptoError $ apnsNotification tkn nonce (paddedNtfLength apnsCfg) pn
|
|
liftIO $ putStrLn $ "APNS notification: " <> show apnsNtf
|
|
req <- liftIO $ apnsRequest c tknStr apnsNtf
|
|
liftIO $ putStrLn $ "APNS request: " <> show req
|
|
HTTP2Response {response, respBody} <- liftHTTPS2 $ sendRequest http2 req
|
|
let status = H.responseStatus response
|
|
reason = fromMaybe "" $ J.decodeStrict' =<< respBody
|
|
liftIO $ putStrLn $ "APNS response: " <> show status <> " " <> T.unpack reason
|
|
result status reason
|
|
where
|
|
result :: Maybe Status -> Text -> ExceptT PushProviderError IO ()
|
|
result status reason
|
|
| status == Just N.ok200 = pure ()
|
|
| status == Just N.badRequest400 =
|
|
case reason of
|
|
"BadDeviceToken" -> throwError PPTokenInvalid
|
|
"DeviceTokenNotForTopic" -> throwError PPTokenInvalid
|
|
"TopicDisallowed" -> throwError PPPermanentError
|
|
_ -> err status reason
|
|
| status == Just N.forbidden403 = case reason of
|
|
"ExpiredProviderToken" -> throwError PPPermanentError -- there should be no point retrying it as the token was refreshed
|
|
"InvalidProviderToken" -> throwError PPPermanentError
|
|
_ -> err status reason
|
|
| status == Just N.gone410 = throwError PPTokenInvalid
|
|
| status == Just N.serviceUnavailable503 = liftIO (disconnectApnsHTTP2Client c) >> throwError PPRetryLater
|
|
-- Just tooManyRequests429 -> TODO TooManyRequests - too many requests for the same token
|
|
| otherwise = err status reason
|
|
err :: Maybe Status -> Text -> ExceptT PushProviderError IO ()
|
|
err s r = throwError $ PPResponseError s r
|
|
liftHTTPS2 a = ExceptT $ first PPConnection <$> a
|
|
|
|
hApnsTopic :: HeaderName
|
|
hApnsTopic = CI.mk "apns-topic"
|
|
|
|
hApnsPushType :: HeaderName
|
|
hApnsPushType = CI.mk "apns-push-type"
|
|
|
|
hApnsPriority :: HeaderName
|
|
hApnsPriority = CI.mk "apns-priority"
|