Compare commits

..
Author SHA1 Message Date
Evgenyandsim ea9adf1ac5 webpush: small fixes (#1699)
* Fix compilation for client lib

* Print VAPID fp

* Fix VAPID signature

* refactor

---------

Co-authored-by: sim <git@sgougeon.fr>
2026-01-19 21:05:28 +00:00
Evgeny 229c8956d9 ntf: e2e encrypt notifications, clean up encryption (#1698) 2026-01-19 20:22:23 +00:00
Evgeny 9c6f32c599 Merge pull request #1663 from p1gp1g/vapid
[webpush] Add VAPID to ntf server
2026-01-16 19:15:21 +00:00
sim 323d6daadf Fix compilation with GHC 8 2025-12-05 14:13:44 +01:00
sim 28aa2da55c Add safety delay for VAPID header expirity 2025-12-05 14:13:44 +01:00
sim 49a7e26a2f Send VAPID header with webpush requests 2025-12-05 14:13:43 +01:00
sim c50c77dbad Add WebPush config with VAPID key to NTF server 2025-12-05 13:45:50 +01:00
sim 3402d64029 Move functions to encode/decode EC keys to Crypto module 2025-12-05 13:44:18 +01:00
23 changed files with 619 additions and 473 deletions
+1 -1
View File
@@ -312,6 +312,7 @@ library
, directory ==1.3.*
, filepath ==1.4.*
, hourglass ==0.2.*
, http-client ==0.7.*
, http-types ==0.12.*
, http2 >=4.2.2 && <4.3
, iproute ==1.7.*
@@ -343,7 +344,6 @@ library
case-insensitive ==1.2.*
, hashable ==1.4.*
, ini ==0.4.1
, http-client ==0.7.*
, http-client-tls ==0.3.6.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
+11 -11
View File
@@ -201,7 +201,7 @@ import qualified Simplex.Messaging.Crypto.ShortLink as SL
import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (ADeviceToken, NtfRegCode (NtfRegCode), NtfTknStatus (..), NtfTokenId, PNMessageData (..), pnMessagesP)
import Simplex.Messaging.Notifications.Protocol (DeviceToken, NtfRegCode (NtfRegCode), NtfTknStatus (..), NtfTokenId, PNMessageData (..), pnMessagesP)
import Simplex.Messaging.Notifications.Types
import Simplex.Messaging.Parsers (defaultJSON, parse)
import Simplex.Messaging.Protocol
@@ -619,24 +619,24 @@ reconnectAllServers c = do
reconnectServerClients c ntfClients
-- | Register device notifications token
registerNtfToken :: AgentClient -> NetworkRequestMode -> ADeviceToken -> NotificationsMode -> AE NtfTknStatus
registerNtfToken :: AgentClient -> NetworkRequestMode -> DeviceToken -> NotificationsMode -> AE NtfTknStatus
registerNtfToken c = withAgentEnv c .:. registerNtfToken' c
{-# INLINE registerNtfToken #-}
-- | Verify device notifications token
verifyNtfToken :: AgentClient -> NetworkRequestMode -> ADeviceToken -> C.CbNonce -> ByteString -> AE ()
verifyNtfToken :: AgentClient -> NetworkRequestMode -> DeviceToken -> C.CbNonce -> ByteString -> AE ()
verifyNtfToken c = withAgentEnv c .:: verifyNtfToken' c
{-# INLINE verifyNtfToken #-}
checkNtfToken :: AgentClient -> NetworkRequestMode -> ADeviceToken -> AE NtfTknStatus
checkNtfToken :: AgentClient -> NetworkRequestMode -> DeviceToken -> AE NtfTknStatus
checkNtfToken c = withAgentEnv c .: checkNtfToken' c
{-# INLINE checkNtfToken #-}
deleteNtfToken :: AgentClient -> ADeviceToken -> AE ()
deleteNtfToken :: AgentClient -> DeviceToken -> AE ()
deleteNtfToken c = withAgentEnv c . deleteNtfToken' c
{-# INLINE deleteNtfToken #-}
getNtfToken :: AgentClient -> AE (ADeviceToken, NtfTknStatus, NotificationsMode, NtfServer)
getNtfToken :: AgentClient -> AE (DeviceToken, NtfTknStatus, NotificationsMode, NtfServer)
getNtfToken c = withAgentEnv c $ getNtfToken' c
{-# INLINE getNtfToken #-}
@@ -2453,7 +2453,7 @@ checkUserServers name srvs =
unless (any (\ServerCfg {enabled} -> enabled) srvs) $
logWarn (name <> ": all passed servers are disabled, using all servers.")
registerNtfToken' :: AgentClient -> NetworkRequestMode -> ADeviceToken -> NotificationsMode -> AM NtfTknStatus
registerNtfToken' :: AgentClient -> NetworkRequestMode -> DeviceToken -> NotificationsMode -> AM NtfTknStatus
registerNtfToken' c nm suppliedDeviceToken suppliedNtfMode =
withStore' c getSavedNtfToken >>= \case
Just tkn@NtfToken {deviceToken = savedDeviceToken, ntfTokenId, ntfTknStatus, ntfTknAction, ntfMode = savedNtfMode} -> do
@@ -2530,7 +2530,7 @@ registerNtfToken' c nm suppliedDeviceToken suppliedNtfMode =
ns <- asks ntfSupervisor
atomically $ nsUpdateToken ns tkn {deviceToken = suppliedDeviceToken, ntfTknStatus = NTRegistered, ntfMode = suppliedNtfMode}
verifyNtfToken' :: AgentClient -> NetworkRequestMode -> ADeviceToken -> C.CbNonce -> ByteString -> AM ()
verifyNtfToken' :: AgentClient -> NetworkRequestMode -> DeviceToken -> C.CbNonce -> ByteString -> AM ()
verifyNtfToken' c nm deviceToken nonce code =
withStore' c getSavedNtfToken >>= \case
Just tkn@NtfToken {deviceToken = savedDeviceToken, ntfTokenId = Just tknId, ntfDhSecret = Just dhSecret, ntfMode} -> do
@@ -2549,7 +2549,7 @@ setCronInterval c nm tknId tkn = do
cron <- asks $ ntfCron . config
void $ forkIO $ void $ runExceptT $ agentNtfSetCronInterval c nm tknId tkn cron
checkNtfToken' :: AgentClient -> NetworkRequestMode -> ADeviceToken -> AM NtfTknStatus
checkNtfToken' :: AgentClient -> NetworkRequestMode -> DeviceToken -> AM NtfTknStatus
checkNtfToken' c nm deviceToken =
withStore' c getSavedNtfToken >>= \case
Just tkn@NtfToken {deviceToken = savedDeviceToken, ntfTokenId = Just tknId, ntfTknAction} -> do
@@ -2563,7 +2563,7 @@ checkNtfToken' c nm deviceToken =
pure status
_ -> throwE $ CMD PROHIBITED "checkNtfToken: no token"
deleteNtfToken' :: AgentClient -> ADeviceToken -> AM ()
deleteNtfToken' :: AgentClient -> DeviceToken -> AM ()
deleteNtfToken' c deviceToken =
withStore' c getSavedNtfToken >>= \case
Just tkn@NtfToken {deviceToken = savedDeviceToken} -> do
@@ -2572,7 +2572,7 @@ deleteNtfToken' c deviceToken =
deleteNtfSubs c NSCSmpDelete
_ -> throwE $ CMD PROHIBITED "deleteNtfToken: no token"
getNtfToken' :: AgentClient -> AM (ADeviceToken, NtfTknStatus, NotificationsMode, NtfServer)
getNtfToken' :: AgentClient -> AM (DeviceToken, NtfTknStatus, NotificationsMode, NtfServer)
getNtfToken' c =
withStore' c getSavedNtfToken >>= \case
Just NtfToken {deviceToken, ntfTknStatus, ntfMode, ntfServer} -> pure (deviceToken, ntfTknStatus, ntfMode, ntfServer)
+2 -2
View File
@@ -1322,7 +1322,7 @@ runNTFServerTest c@AgentClient {presetDomains} nm userId (ProtoServerWithAuth sr
(nKey, npKey) <- atomically $ C.generateAuthKeyPair a g
(dhKey, _) <- atomically $ C.generateKeyPair g
r <- runExceptT $ do
let deviceToken = ADT SAPNS $ APNSDeviceToken PPApnsNull "test_ntf_token"
let deviceToken = APNSDeviceToken PPApnsNull "test_ntf_token"
(tknId, _) <- liftError (testErr TSCreateNtfToken) $ ntfRegisterToken ntf nm npKey (NewNtfTkn deviceToken nKey dhKey)
liftError (testErr TSDeleteNtfToken) $ ntfDeleteToken ntf nm npKey tknId
ok <- netTimeoutInt (tcpTimeout $ networkConfig cfg) nm `timeout` closeProtocolClient ntf
@@ -1972,7 +1972,7 @@ agentNtfCheckToken :: AgentClient -> NetworkRequestMode -> NtfTokenId -> NtfToke
agentNtfCheckToken c nm tknId NtfToken {ntfServer, ntfPrivKey} =
withNtfClient c nm ntfServer tknId "TCHK" $ \ntf -> ntfCheckToken ntf nm ntfPrivKey tknId
agentNtfReplaceToken :: AgentClient -> NetworkRequestMode -> NtfTokenId -> NtfToken -> ADeviceToken -> AM ()
agentNtfReplaceToken :: AgentClient -> NetworkRequestMode -> NtfTokenId -> NtfToken -> DeviceToken -> AM ()
agentNtfReplaceToken c nm tknId NtfToken {ntfServer, ntfPrivKey} token =
withNtfClient c nm ntfServer tknId "TRPL" $ \ntf -> ntfReplaceToken ntf nm ntfPrivKey tknId token
@@ -294,7 +294,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), Ratc
import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (ADeviceToken (..), NtfSubscriptionId, NtfTknStatus (..), NtfTokenId, SMPQueueNtf (..), deviceToken', deviceTokenFields)
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfSubscriptionId, NtfTknStatus (..), NtfTokenId, SMPQueueNtf (..), deviceTokenFields, deviceToken')
import Simplex.Messaging.Notifications.Types
import Simplex.Messaging.Parsers (parseAll)
import Simplex.Messaging.Protocol
@@ -1464,7 +1464,7 @@ updateNtfTokenRegistration db NtfToken {deviceToken, ntfServer = ProtocolServer
|]
(tknId, ntfDhSecret, NTRegistered, Nothing :: Maybe NtfTknAction, updatedAt, provider, token, host, port)
updateDeviceToken :: DB.Connection -> NtfToken -> ADeviceToken -> IO ()
updateDeviceToken :: DB.Connection -> NtfToken -> DeviceToken -> IO ()
updateDeviceToken db NtfToken {deviceToken, ntfServer = ProtocolServer {host, port}} toDt = do
let (provider, token) = deviceTokenFields deviceToken
updatedAt <- getCurrentTime
+68 -6
View File
@@ -87,6 +87,7 @@ module Simplex.Messaging.Crypto
signatureKeyPair,
publicToX509,
encodeASNObj,
readECPrivateKey,
-- * key encoding/decoding
encodePubKey,
@@ -94,6 +95,10 @@ module Simplex.Messaging.Crypto
encodePrivKey,
decodePrivKey,
pubKeyBytes,
encodeBigInt,
uncompressEncodePoint,
uncompressDecodePoint,
uncompressDecodePrivateNumber,
-- * sign/verify
Signature (..),
@@ -211,24 +216,29 @@ import Control.Exception (Exception)
import Control.Monad
import Control.Monad.Except
import Control.Monad.Trans.Except
import Crypto.Cipher.AES (AES256, AES128)
import Crypto.Cipher.AES (AES128, AES256)
import qualified Crypto.Cipher.Types as AES
import qualified Crypto.Cipher.XSalsa as XSalsa
import qualified Crypto.Error as CE
import Crypto.Hash (Digest, SHA3_256, SHA3_384, SHA256 (..), SHA512 (..), hash, hashDigestSize)
import Crypto.Hash (Digest, SHA256 (..), SHA3_256, SHA3_384, SHA512 (..), hash, hashDigestSize)
import qualified Crypto.KDF.HKDF as H
import qualified Crypto.MAC.Poly1305 as Poly1305
import qualified Crypto.PubKey.Curve25519 as X25519
import qualified Crypto.PubKey.Curve448 as X448
import qualified Crypto.PubKey.ECC.ECDSA as ECDSA
import qualified Crypto.PubKey.ECC.Types as ECC
import qualified Crypto.PubKey.Ed25519 as Ed25519
import qualified Crypto.PubKey.Ed448 as Ed448
import Crypto.Random (ChaChaDRG, MonadPseudoRandom, drgNew, randomBytesGenerate, withDRG)
import qualified Crypto.Store.PKCS8 as PK
import Data.ASN1.BinaryEncoding
import Data.ASN1.Encoding
import Data.ASN1.Types
import Data.Aeson (FromJSON (..), ToJSON (..))
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Bifunctor (bimap, first)
import qualified Data.Binary as Bin
import qualified Data.Bits as Bits
import Data.ByteArray (ByteArrayAccess)
import qualified Data.ByteArray as BA
import Data.ByteString.Base64 (decode, encode)
@@ -236,13 +246,14 @@ import qualified Data.ByteString.Base64.URL as U
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.ByteString.Lazy (fromStrict, toStrict)
import qualified Data.ByteString.Lazy as LB
import Data.Constraint (Dict (..))
import Data.Kind (Constraint, Type)
import qualified Data.List.NonEmpty as L
import Data.String
import Data.Type.Equality
import Data.Typeable (Proxy (Proxy), Typeable)
import Data.Word (Word32)
import Data.Word (Word32, Word64)
import qualified Data.X509 as X
import Data.X509.Validation (Fingerprint (..), getFingerprint)
import GHC.TypeLits (ErrorMessage (..), KnownNat, Nat, TypeError, natVal, type (+))
@@ -1252,11 +1263,11 @@ instance SignatureAlgorithmX509 pk => SignatureAlgorithmX509 (a, pk) where
-- | A wrapper to marshall signed ASN1 objects, like certificates.
newtype SignedObject a = SignedObject {getSignedExact :: X.SignedExact a}
instance (Typeable a, Eq a, Show a, ASN1Object a) => FromField (SignedObject a) where
instance (Typeable a, Eq a, Show a, ASN1Object a) => FromField (SignedObject a)
#if defined(dbPostgres)
fromField f dat = SignedObject <$> blobFieldDecoder X.decodeSignedObject f dat
where fromField f dat = SignedObject <$> blobFieldDecoder X.decodeSignedObject f dat
#else
fromField = fmap SignedObject . blobFieldDecoder X.decodeSignedObject
where fromField = fmap SignedObject . blobFieldDecoder X.decodeSignedObject
#endif
instance (Eq a, Show a, ASN1Object a) => ToField (SignedObject a) where
@@ -1542,3 +1553,54 @@ keyError :: (a, [ASN1]) -> Either String b
keyError = \case
(_, []) -> Left "unknown key algorithm"
_ -> Left "more than one key"
readECPrivateKey :: FilePath -> IO ECDSA.PrivateKey
readECPrivateKey f = do
-- this pattern match is specific to APNS key type, it may need to be extended for other push providers
[PK.Unprotected (X.PrivKeyEC X.PrivKeyEC_Named {privkeyEC_name, privkeyEC_priv})] <- PK.readKeyFile f
pure ECDSA.PrivateKey {private_curve = ECC.getCurveByName privkeyEC_name, private_d = privkeyEC_priv}
-- | Elliptic-Curve-Point-to-Octet-String Conversion without compression
-- | as required by RFC8291
-- | https://www.secg.org/sec1-v2.pdf#subsubsection.2.3.3
uncompressEncodePoint :: ECC.Point -> ByteString
uncompressEncodePoint (ECC.Point x y) = "\x04" <> encodeBigInt x <> encodeBigInt y
uncompressEncodePoint ECC.PointO = "\0"
uncompressDecodePoint :: ByteString -> Either String ECC.Point
uncompressDecodePoint "\0" = pure ECC.PointO
uncompressDecodePoint s
| B.take 1 s /= prefix = Left "PointFormatUnsupported"
| B.length s /= 65 = Left "KeySizeInvalid"
| otherwise = do
let s' = B.drop 1 s
x <- decodeBigInt $ B.take 32 s'
y <- decodeBigInt $ B.drop 32 s'
pure $ ECC.Point x y
where
prefix = "\x04" :: ByteString
-- Used to test encryption against the RFC8291 Example - which gives the AS private key
uncompressDecodePrivateNumber :: ByteString -> Either String ECC.PrivateNumber
uncompressDecodePrivateNumber s
| B.length s /= 32 = Left "KeySizeInvalid"
| otherwise = decodeBigInt s
encodeBigInt :: Integer -> ByteString
encodeBigInt i =
let s1 = Bits.shiftR i 64
s2 = Bits.shiftR s1 64
s3 = Bits.shiftR s2 64
in LB.toStrict $ Bin.encode (w64 s3, w64 s2, w64 s1, w64 i)
where
w64 :: Integer -> Word64
w64 = fromIntegral
decodeBigInt :: ByteString -> Either String Integer
decodeBigInt s
| B.length s /= 32 = Left "PointSizeInvalid"
| otherwise =
let (w3, w2, w1, w0) = Bin.decode (LB.fromStrict s) :: (Bin.Word64, Bin.Word64, Bin.Word64, Bin.Word64)
in Right $ shift 3 w3 + shift 2 w2 + shift 1 w1 + fromIntegral w0
where
shift i w = Bits.shiftL (fromIntegral w) (64 * i)
@@ -43,7 +43,7 @@ ntfCheckToken c nm pKey tknId =
NRTkn stat -> pure stat
r -> throwE $ unexpectedResponse r
ntfReplaceToken :: NtfClient -> NetworkRequestMode -> C.APrivateAuthKey -> NtfTokenId -> ADeviceToken -> ExceptT NtfClientError IO ()
ntfReplaceToken :: NtfClient -> NetworkRequestMode -> C.APrivateAuthKey -> NtfTokenId -> DeviceToken -> ExceptT NtfClientError IO ()
ntfReplaceToken c nm pKey tknId token = okNtfCommand (TRPL token) c nm pKey tknId
ntfDeleteToken :: NtfClient -> NetworkRequestMode -> C.APrivateAuthKey -> NtfTokenId -> ExceptT NtfClientError IO ()
+69 -185
View File
@@ -1,5 +1,4 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
@@ -28,7 +27,8 @@ import Data.Maybe (isNothing)
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock.System
import Data.Type.Equality
import Data.Word (Word16, Word64)
import Data.Word (Word16)
import Network.HTTP.Client (Request, parseUrlThrow)
import Simplex.Messaging.Agent.Protocol (updateSMPServerHosts)
import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_)
import qualified Simplex.Messaging.Crypto as C
@@ -37,10 +37,6 @@ import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Transport (NTFVersion, invalidReasonNTFVersion, ntfClientHandshake)
import Simplex.Messaging.Protocol hiding (Command (..), CommandTag (..))
import Simplex.Messaging.Util (eitherToMaybe, (<$?>))
import qualified Data.ByteString.Lazy as BL
import qualified Data.Binary as Bin
import qualified Data.Bits as Bits
import Network.HTTP.Client (Request, parseUrlThrow)
data NtfEntity = Token | Subscription
deriving (Show)
@@ -133,7 +129,7 @@ instance ToJSON NtfRegCode where
toEncoding = strToJEncoding
data NewNtfEntity (e :: NtfEntity) where
NewNtfTkn :: ADeviceToken -> NtfPublicAuthKey -> C.PublicKeyX25519 -> NewNtfEntity 'Token
NewNtfTkn :: DeviceToken -> NtfPublicAuthKey -> C.PublicKeyX25519 -> NewNtfEntity 'Token
NewNtfSub :: NtfTokenId -> SMPQueueNtf -> NtfPrivateAuthKey -> NewNtfEntity 'Subscription
deriving instance Show (NewNtfEntity e)
@@ -178,7 +174,7 @@ data NtfCommand (e :: NtfEntity) where
-- | check token status
TCHK :: NtfCommand 'Token
-- | replace device token (while keeping all existing subscriptions)
TRPL :: ADeviceToken -> NtfCommand 'Token
TRPL :: DeviceToken -> NtfCommand 'Token
-- | delete token - all subscriptions will be removed and no more notifications will be sent
TDEL :: NtfCommand 'Token
-- | enable periodic background notification to fetch the new messages - interval is in minutes, minimum is 20, 0 to disable
@@ -378,58 +374,8 @@ instance StrEncoding SMPQueueNtf where
notifierId <- A.char '/' *> strP
pure SMPQueueNtf {smpServer, notifierId}
data PushType = APNS | WebPush deriving (Eq, Ord, Show)
data SPushType (p :: PushType) where
SAPNS :: SPushType 'APNS
SWebPush :: SPushType 'WebPush
toPushType :: SPushType p -> PushType
toPushType = \case
SAPNS -> APNS
SWebPush -> WebPush
deriving instance Show (SPushType p)
class PushTypeI (p :: PushType) where sPushType :: SPushType p
instance PushTypeI 'APNS where sPushType = SAPNS
instance PushTypeI 'WebPush where sPushType = SWebPush
instance TestEquality SPushType where
testEquality SAPNS SAPNS = Just Refl
testEquality SWebPush SWebPush = Just Refl
testEquality _ _ = Nothing
checkPushType :: forall t p p'. (PushTypeI p, PushTypeI p') => t p' -> Either String (t p)
checkPushType c = case testEquality (sPushType @p) (sPushType @p') of
Just Refl -> Right c
Nothing -> Left "bad push type"
data PushProvider (p :: PushType) where
PPAPNS :: APNSProvider -> PushProvider 'APNS
PPWP :: WPProvider -> PushProvider 'WebPush
deriving instance Eq (PushProvider p)
deriving instance Ord (PushProvider p)
deriving instance Show (PushProvider p)
data APushProvider = forall p. PushTypeI p => APP (SPushType p) (PushProvider p)
instance Eq APushProvider where
APP pt p == APP pt' p' = case testEquality pt pt' of
Just Refl -> p == p'
Nothing -> False
instance Ord APushProvider where
APP pt t `compare` APP pt' t' = case testEquality pt pt' of
Just Refl -> t `compare` t'
Nothing -> toPushType pt `compare` toPushType pt'
deriving instance Show APushProvider
data PushProvider = PPAPNS APNSProvider | PPWP WPProvider
deriving (Eq, Ord, Show)
data APNSProvider
= PPApnsDev -- provider for Apple development environment
@@ -444,18 +390,17 @@ newtype WPSrvLoc = WPSrvLoc SrvLoc
newtype WPProvider = WPP WPSrvLoc
deriving (Eq, Ord, Show)
instance PushTypeI p => Encoding (PushProvider p) where
wpAud :: WPProvider -> B.ByteString
wpAud (WPP (WPSrvLoc (SrvLoc aud _))) = B.pack aud
instance Encoding PushProvider where
smpEncode = \case
PPAPNS p -> smpEncode p
PPWP p -> smpEncode p
smpP = (\(APP _ p) -> checkPushType p) <$?> smpP
instance Encoding APushProvider where
smpEncode (APP _ p) = smpEncode p
smpP =
A.peekChar' >>= \case
'A' -> APP SAPNS . PPAPNS <$> smpP
_ -> APP SWebPush . PPWP <$> smpP
'A' -> PPAPNS <$> smpP
_ -> PPWP <$> smpP
instance Encoding APNSProvider where
smpEncode = \case
@@ -471,18 +416,14 @@ instance Encoding APNSProvider where
"AN" -> pure PPApnsNull
_ -> fail "bad APNSProvider"
instance PushTypeI p => StrEncoding (PushProvider p) where
instance StrEncoding PushProvider where
strEncode = \case
PPAPNS p -> strEncode p
PPWP p -> strEncode p
strP = (\(APP _ p) -> checkPushType p) <$?> strP
instance StrEncoding APushProvider where
strEncode (APP _ p) = strEncode p
strP =
A.peekChar' >>= \case
'a' -> APP SAPNS . PPAPNS <$> strP
_ -> APP SWebPush . PPWP <$> strP
'a' -> PPAPNS <$> strP
_ -> PPWP <$> strP
instance StrEncoding APNSProvider where
strEncode = \case
@@ -514,9 +455,9 @@ instance StrEncoding WPProvider where
strEncode (WPP srv) = "webpush " <> strEncode srv
strP = WPP <$> ("webpush " *> strP)
instance FromField APushProvider where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8
instance FromField PushProvider where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8
instance ToField APushProvider where toField = toField . decodeLatin1 . strEncode
instance ToField PushProvider where toField = toField . decodeLatin1 . strEncode
newtype WPAuth = WPAuth {unWPAuth :: ByteString} deriving (Eq, Ord, Show)
@@ -542,55 +483,13 @@ data WPKey = WPKey
}
deriving (Eq, Ord, Show)
-- | Elliptic-Curve-Point-to-Octet-String Conversion without compression
-- | as required by RFC8291
-- | https://www.secg.org/sec1-v2.pdf#subsubsection.2.3.3
uncompressEncodePoint :: ECC.Point -> ByteString
uncompressEncodePoint (ECC.Point x y) = "\x04" <> encodeBigInt x <> encodeBigInt y
uncompressEncodePoint ECC.PointO = "\0"
uncompressDecodePoint :: ByteString -> Either String ECC.Point
uncompressDecodePoint "\0" = pure ECC.PointO
uncompressDecodePoint s
| B.null s = Left "KeySizeInvalid"
| B.head s /= '\x04' = Left "PointFormatUnsupported"
| B.length s /= 65 = Left "KeySizeInvalid"
| otherwise = do
let s' = B.drop 1 s
x <- decodeBigInt $ B.take 32 s'
y <- decodeBigInt $ B.drop 32 s'
pure $ ECC.Point x y
-- Used to test encryption against the RFC8291 Example - which gives the AS private key
uncompressDecodePrivateNumber :: ByteString -> Either String ECC.PrivateNumber
uncompressDecodePrivateNumber s
| B.length s /= 32 = Left "KeySizeInvalid"
| otherwise = decodeBigInt s
uncompressEncode :: WPP256dh -> ByteString
uncompressEncode (WPP256dh p) = uncompressEncodePoint p
uncompressEncode (WPP256dh p) = C.uncompressEncodePoint p
{-# INLINE uncompressEncode #-}
uncompressDecode :: ByteString -> Either String WPP256dh
uncompressDecode bs = WPP256dh <$> uncompressDecodePoint bs
encodeBigInt :: Integer -> ByteString
encodeBigInt i =
let s1 = Bits.shiftR i 64
s2 = Bits.shiftR s1 64
s3 = Bits.shiftR s2 64
in BL.toStrict $ Bin.encode (w64 s3, w64 s2, w64 s1, w64 i)
where
w64 :: Integer -> Word64
w64 = fromIntegral
decodeBigInt :: ByteString -> Either String Integer
decodeBigInt s
| B.length s /= 32 = Left "PointSizeInvalid"
| otherwise = do
let (w3, w2, w1, w0) = Bin.decode (BL.fromStrict s) :: (Word64, Word64, Word64, Word64)
in Right $ shift 3 w3 + shift 2 w2 + shift 1 w1 + fromIntegral w0
where
shift i w = Bits.shiftL (fromIntegral w) (64 * i)
uncompressDecode bs = WPP256dh <$> C.uncompressDecodePoint bs
{-# INLINE uncompressDecode #-}
data WPTokenParams = WPTokenParams
{ wpPath :: ByteString,
@@ -607,12 +506,16 @@ instance StrEncoding WPAuth where
strP = toWPAuth <$?> strP
instance Encoding WPP256dh where
smpEncode p = smpEncode $ uncompressEncode p
smpEncode = smpEncode . uncompressEncode
{-# INLINE smpEncode #-}
smpP = uncompressDecode <$?> smpP
{-# INLINE smpP #-}
instance StrEncoding WPP256dh where
strEncode p = strEncode $ uncompressEncode p
strEncode = strEncode . uncompressEncode
{-# INLINE strEncode #-}
strP = uncompressDecode <$?> strP
{-# INLINE strP #-}
instance Encoding WPKey where
smpEncode WPKey {wpAuth, wpP256dh} = smpEncode (wpAuth, wpP256dh)
@@ -642,102 +545,83 @@ instance StrEncoding WPTokenParams where
wpKey <- strP
pure WPTokenParams {wpPath, wpKey}
data DeviceToken (p :: PushType) where
APNSDeviceToken :: APNSProvider -> ByteString -> DeviceToken 'APNS
WPDeviceToken :: WPProvider -> WPTokenParams -> DeviceToken 'WebPush
data DeviceToken
= APNSDeviceToken APNSProvider ByteString
| WPDeviceToken WPProvider WPTokenParams
deriving (Eq, Ord, Show)
deriving instance Eq (DeviceToken p)
deriving instance Ord (DeviceToken p)
deriving instance Show (DeviceToken p)
data ADeviceToken = forall p. PushTypeI p => ADT (SPushType p) (DeviceToken p)
instance Eq ADeviceToken where
ADT p t == ADT p' t' = case testEquality p p' of
Just Refl -> t == t'
Nothing -> False
instance Ord ADeviceToken where
ADT p t `compare` ADT p' t' = case testEquality p p' of
Just Refl -> t `compare` t'
Nothing -> toPushType p `compare` toPushType p'
deriving instance Show ADeviceToken
tokenPushProvider :: DeviceToken p -> PushProvider p
tokenPushProvider :: DeviceToken -> PushProvider
tokenPushProvider = \case
APNSDeviceToken p _ -> PPAPNS p
WPDeviceToken p _ -> PPWP p
APNSDeviceToken pp _ -> PPAPNS pp
WPDeviceToken pp _ -> PPWP pp
instance PushTypeI p => Encoding (DeviceToken p) where
smpEncode = \case
instance Encoding DeviceToken where
smpEncode token = case token of
APNSDeviceToken p t -> smpEncode (p, t)
WPDeviceToken p t -> smpEncode (p, t)
smpP = (\(ADT _ t) -> checkPushType t) <$?> smpP
instance Encoding ADeviceToken where
smpEncode (ADT _ t) = smpEncode t
smpP =
smpP >>= \case
APP _ (PPAPNS p) -> ADT SAPNS . APNSDeviceToken p <$> smpP
APP _ (PPWP p) -> ADT SWebPush . WPDeviceToken p <$> smpP
PPAPNS p -> APNSDeviceToken p <$> smpP
PPWP p -> WPDeviceToken p <$> smpP
instance PushTypeI p => StrEncoding (DeviceToken p) where
instance StrEncoding DeviceToken where
strEncode token = case token of
APNSDeviceToken p t -> strEncode p <> " " <> t
-- We don't do strEncode (p, t), because we don't want any space between
-- p (e.g. webpush https://localhost) and t.wpPath (e.g /random)
WPDeviceToken p t -> strEncode p <> strEncode t
strP = (\(ADT _ t) -> checkPushType t) <$?> strP
instance StrEncoding ADeviceToken where
strEncode (ADT _ t) = strEncode t
strP = nullToken <|> deviceToken
where
nullToken = "apns_null test_ntf_token" $> ADT SAPNS (APNSDeviceToken PPApnsNull "test_ntf_token")
deviceToken :: A.Parser ADeviceToken
nullToken = "apns_null test_ntf_token" $> APNSDeviceToken PPApnsNull "test_ntf_token"
deviceToken =
strP >>= \case
APP _ (PPAPNS p) -> ADT SAPNS . APNSDeviceToken p <$> hexStringP
APP _ (PPWP p) -> do
PPAPNS p -> APNSDeviceToken p <$> hexStringP
PPWP p -> do
t <- WPDeviceToken p <$> strP
_ <- wpRequest t
pure $ ADT SWebPush t
pure t
hexStringP = do
_ <- A.space
A.takeWhile (`B.elem` "0123456789abcdef") >>= \s ->
if even (B.length s) then pure s else fail "odd number of hex characters"
instance ToJSON (DeviceToken 'APNS) where
toEncoding = \case
instance ToJSON DeviceToken where
toEncoding token = case token of
APNSDeviceToken p t -> J.pairs $ "pushProvider" .= decodeLatin1 (strEncode p) <> "token" .= decodeLatin1 t
toJSON = \case
APNSDeviceToken p t -> J.object ["pushProvider" .= decodeLatin1 (strEncode p), "token" .= decodeLatin1 t]
-- ToJSON/FromJSON isn't used for WPDeviceToken, we just include the pushProvider so it can fail properly if used to decrypt
WPDeviceToken p _ -> J.pairs $ "pushProvider" .= decodeLatin1 (strEncode p)
instance FromJSON (DeviceToken 'APNS) where
-- WPDeviceToken p t -> J.pairs $ "pushProvider" .= decodeLatin1 (strEncode p) <> "token" .= toJSON t
toJSON token = case token of
APNSDeviceToken p t -> J.object ["pushProvider" .= decodeLatin1 (strEncode p), "token" .= decodeLatin1 t]
-- ToJSON/FromJSON isn't used for WPDeviceToken, we just include the pushProvider so it can fail properly if used to decrypt
WPDeviceToken p _ -> J.object ["pushProvider" .= decodeLatin1 (strEncode p)]
-- WPDeviceToken p t -> J.object ["pushProvider" .= decodeLatin1 (strEncode p), "token" .= toJSON t]
instance FromJSON DeviceToken where
parseJSON = J.withObject "DeviceToken" $ \o ->
(strDecode @(PushProvider 'APNS) . encodeUtf8 <$?> o .: "pushProvider") >>= \case
(strDecode . encodeUtf8 <$?> o .: "pushProvider") >>= \case
PPAPNS p -> APNSDeviceToken p . encodeUtf8 <$> (o .: "token")
PPWP _ -> fail "FromJSON not implemented for WPDeviceToken"
-- | Returns fields for the device token (pushProvider, token)
-- TODO [webpush] save token as separate fields
deviceTokenFields :: ADeviceToken -> (APushProvider, ByteString)
deviceTokenFields = \case
ADT _ (APNSDeviceToken p t) -> (APP SAPNS (PPAPNS p), t)
ADT _ (WPDeviceToken p t) -> (APP SWebPush (PPWP p), strEncode t)
deviceTokenFields :: DeviceToken -> (PushProvider, ByteString)
deviceTokenFields dt = case dt of
APNSDeviceToken p t -> (PPAPNS p, t)
WPDeviceToken p t -> (PPWP p, strEncode t)
-- | Returns the device token from the fields (pushProvider, token)
-- TODO [webpush] read token as separate fields, don't use `error`
deviceToken' :: APushProvider -> ByteString -> ADeviceToken
deviceToken' :: PushProvider -> ByteString -> DeviceToken
deviceToken' pp t = case pp of
APP _ (PPAPNS p) -> ADT SAPNS $ APNSDeviceToken p t
APP _ (PPWP p) -> ADT SWebPush . WPDeviceToken p <$> either error id $ strDecode t
PPAPNS p -> APNSDeviceToken p t
PPWP p -> WPDeviceToken p <$> either error id $ strDecode t
wpRequest :: MonadFail m => DeviceToken 'WebPush -> m Request
wpRequest (WPDeviceToken (WPP s) params) = do
let endpoint = strEncode s <> wpPath params
wpRequest :: MonadFail m => DeviceToken -> m Request
wpRequest (APNSDeviceToken _ _) = fail "Invalid device token"
wpRequest (WPDeviceToken (WPP s) param) = do
let endpoint = strEncode s <> wpPath param
case parseUrlThrow $ B.unpack endpoint of
Left _ -> fail "Invalid URL"
Right r -> pure r
+12 -12
View File
@@ -629,18 +629,18 @@ showServer' = decodeLatin1 . strEncode . host
ntfPush :: NtfPushServer -> M ()
ntfPush s@NtfPushServer {pushQ} = forever $ do
(srvHost_, tkn@NtfTknRec {ntfTknId, token = t@(ADT _ token), tknStatus}, ntf) <- atomically (readTBQueue pushQ)
logDebug $ "sending push notification to " <> tshow (tokenPushProvider token)
(srvHost_, tkn@NtfTknRec {ntfTknId, token = t, tknStatus}, ntf) <- atomically (readTBQueue pushQ)
logDebug $ "sending push notification to " <> tshow (tokenPushProvider t)
st <- asks store
case ntf of
PNVerification _ ->
liftIO (deliverNotification st tkn token ntf) >>= \case
liftIO (deliverNotification st tkn ntf) >>= \case
Right _ -> do
void $ liftIO $ setTknStatusConfirmed st tkn
incNtfStatT t ntfVrfDelivered
Left _ -> incNtfStatT t ntfVrfFailed
PNCheckMessages -> do
liftIO (deliverNotification st tkn token ntf) >>= \case
liftIO (deliverNotification st tkn ntf) >>= \case
Right _ -> do
void $ liftIO $ updateTokenCronSentAt st ntfTknId . systemSeconds =<< getSystemTime
incNtfStatT t ntfCronDelivered
@@ -648,7 +648,7 @@ ntfPush s@NtfPushServer {pushQ} = forever $ do
PNMessage {} -> checkActiveTkn tknStatus $ do
stats <- asks serverStats
liftIO $ updatePeriodStats (activeTokens stats) ntfTknId
liftIO (deliverNotification st tkn token ntf) >>= \case
liftIO (deliverNotification st tkn ntf) >>= \case
Left _ -> do
incNtfStatT t ntfFailed
liftIO $ mapM_ (`incServerStat` ntfFailedOwn stats) srvHost_
@@ -661,10 +661,10 @@ ntfPush s@NtfPushServer {pushQ} = forever $ do
checkActiveTkn status action
| status == NTActive = action
| otherwise = liftIO $ logError "bad notification token status"
deliverNotification :: NtfPostgresStore -> NtfTknRec -> DeviceToken p -> PushNotification -> IO (Either PushProviderError ())
deliverNotification st tkn@NtfTknRec {ntfTknId} token ntf = do
deliverNotification :: NtfPostgresStore -> NtfTknRec -> PushNotification -> IO (Either PushProviderError ())
deliverNotification st tkn@NtfTknRec {ntfTknId, token} ntf = do
deliver <- getPushClient s pp
runExceptT (deliver tkn token ntf) >>= \case
runExceptT (deliver tkn ntf) >>= \case
Right _ -> pure $ Right ()
Left e -> case e of
PPConnection _ -> retryDeliver
@@ -675,13 +675,14 @@ ntfPush s@NtfPushServer {pushQ} = forever $ do
void $ updateTknStatus st tkn $ NTInvalid $ Just r
err e
PPPermanentError -> err e
PPInvalidPusher -> err e
_ -> err e
where
pp = tokenPushProvider token
retryDeliver :: IO (Either PushProviderError ())
retryDeliver = do
deliver <- newPushClient s pp
runExceptT (deliver tkn token ntf) >>= \case
runExceptT (deliver tkn ntf) >>= \case
Right _ -> pure $ Right ()
Left e -> case e of
PPTokenInvalid r -> do
@@ -906,9 +907,8 @@ withNtfStore stAction continue = do
Left e -> pure $ NRErr e
Right a -> continue a
-- TODO [webpush] track webpush statistics separately
incNtfStatT :: ADeviceToken -> (NtfServerStats -> IORef Int) -> M ()
incNtfStatT (ADT _ (APNSDeviceToken PPApnsNull _)) _ = pure ()
incNtfStatT :: DeviceToken -> (NtfServerStats -> IORef Int) -> M ()
incNtfStatT (APNSDeviceToken PPApnsNull _) _ = pure ()
incNtfStatT _ statSel = incNtfStat statSel
{-# INLINE incNtfStatT #-}
@@ -1,8 +1,8 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
@@ -12,12 +12,15 @@ import Control.Concurrent (ThreadId)
import Control.Logger.Simple
import Control.Monad
import Crypto.Random
import Data.IORef (newIORef)
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime)
import Data.Time.Clock.System (SystemTime)
import qualified Data.X509.Validation as XV
import Network.HTTP.Client (Manager, ManagerSettings (..), Request (..), newManager)
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.Socket
import qualified Network.TLS as TLS
import Numeric.Natural
@@ -27,6 +30,7 @@ import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Notifications.Protocol
import Simplex.Messaging.Notifications.Server.Push
import Simplex.Messaging.Notifications.Server.Push.APNS
import Simplex.Messaging.Notifications.Server.Push.WebPush (WebPushClient (..), WebPushConfig, wpPushProviderClient)
import Simplex.Messaging.Notifications.Server.Stats
import Simplex.Messaging.Notifications.Server.Store (newNtfSTMStore)
import Simplex.Messaging.Notifications.Server.Store.Postgres
@@ -46,9 +50,6 @@ import Simplex.Messaging.Transport.Server (AddHTTP, ServerCredentials, Transport
import System.Exit (exitFailure)
import System.Mem.Weak (Weak)
import UnliftIO.STM
import Simplex.Messaging.Notifications.Server.Push.WebPush (wpPushProviderClient)
import Network.HTTP.Client (newManager, ManagerSettings (..), Request (..), Manager)
import Network.HTTP.Client.TLS (tlsManagerSettings)
data NtfServerConfig = NtfServerConfig
{ transports :: [(ServiceName, ASrvTransport, AddHTTP)],
@@ -61,6 +62,7 @@ data NtfServerConfig = NtfServerConfig
pushQSize :: Natural,
smpAgentCfg :: SMPClientAgentConfig,
apnsConfig :: APNSPushClientConfig,
wpConfig :: WebPushConfig,
subsBatchSize :: Int,
inactiveClientExpiration :: Maybe ExpirationConfig,
dbStoreConfig :: PostgresStoreCfg,
@@ -100,7 +102,7 @@ data NtfEnv = NtfEnv
}
newNtfServerEnv :: NtfServerConfig -> IO NtfEnv
newNtfServerEnv config@NtfServerConfig {pushQSize, smpAgentCfg, apnsConfig, dbStoreConfig, ntfCredentials, useServiceCreds, startOptions} = do
newNtfServerEnv config@NtfServerConfig {pushQSize, smpAgentCfg, apnsConfig, wpConfig, dbStoreConfig, ntfCredentials, useServiceCreds, startOptions} = do
when (compactLog startOptions) $ compactDbStoreLog $ dbStoreLogPath dbStoreConfig
random <- C.newRandom
store <- newNtfDbStore dbStoreConfig
@@ -116,7 +118,7 @@ newNtfServerEnv config@NtfServerConfig {pushQSize, smpAgentCfg, apnsConfig, dbSt
pure smpAgentCfg {smpCfg = (smpCfg smpAgentCfg) {serviceCredentials = Just service}}
else pure smpAgentCfg
subscriber <- newNtfSubscriber smpAgentCfg' random
pushServer <- newNtfPushServer pushQSize apnsConfig
pushServer <- newNtfPushServer pushQSize apnsConfig wpConfig
serverStats <- newNtfServerStats =<< getCurrentTime
pure NtfEnv {config, subscriber, pushServer, store, random, tlsServerCreds, serverIdentity = C.KeyHash fp, serverStats}
where
@@ -152,52 +154,55 @@ data SMPSubscriber = SMPSubscriber
data NtfPushServer = NtfPushServer
{ pushQ :: TBQueue (Maybe T.Text, NtfTknRec, PushNotification), -- Maybe Text is a hostname of "own" server
apnsPushClients :: TMap APNSProvider (PushProviderClient 'APNS),
webPushClients :: TMap WPProvider (PushProviderClient 'WebPush),
apnsConfig :: APNSPushClientConfig
pushClients :: TMap PushProvider PushProviderClient,
apnsConfig :: APNSPushClientConfig,
wpConfig :: WebPushConfig
}
newNtfPushServer :: Natural -> APNSPushClientConfig -> IO NtfPushServer
newNtfPushServer qSize apnsConfig = do
newNtfPushServer :: Natural -> APNSPushClientConfig -> WebPushConfig -> IO NtfPushServer
newNtfPushServer qSize apnsConfig wpConfig = do
pushQ <- newTBQueueIO qSize
apnsPushClients <- TM.emptyIO
webPushClients <- TM.emptyIO
pure NtfPushServer {pushQ, apnsPushClients, webPushClients, apnsConfig}
pushClients <- TM.emptyIO
pure NtfPushServer {pushQ, pushClients, apnsConfig, wpConfig}
newPushClient :: NtfPushServer -> PushProvider p -> IO (PushProviderClient p)
newPushClient s = \case
PPWP p -> newWPPushClient s p
PPAPNS p -> newAPNSPushClient s p
newAPNSPushClient :: NtfPushServer -> APNSProvider -> IO (PushProviderClient 'APNS)
newAPNSPushClient NtfPushServer {apnsConfig, apnsPushClients} pp = case apnsProviderHost pp of
Nothing -> pure $ \_ _ _ -> pure ()
Just host -> do
c <- apnsPushProviderClient <$> createAPNSPushClient host apnsConfig
atomically $ TM.insert pp c apnsPushClients
pure c
newWPPushClient :: NtfPushServer -> WPProvider -> IO (PushProviderClient 'WebPush)
newWPPushClient NtfPushServer {webPushClients} pp = do
logDebug "New WP Client requested"
-- We use one http manager per push server (which may be used by different clients)
c <- wpPushProviderClient <$> wpHTTPManager
atomically $ TM.insert pp c webPushClients
newPushClient :: NtfPushServer -> PushProvider -> IO PushProviderClient
newPushClient s pp = do
c <- case pp of
PPWP p -> newWPPushClient s p
PPAPNS p -> newAPNSPushClient s p
atomically $ TM.insert pp c $ pushClients s
pure c
wpHTTPManager :: IO Manager
wpHTTPManager = newManager tlsManagerSettings {
-- Ideally, we should be able to override the domain resolution to
-- disable requests to non-public IPs. The risk is very limited as
-- we allow https only, and the body is encrypted. Disabling redirections
-- avoids cross-protocol redir (https => http/unix)
managerModifyRequest = \r -> pure r {redirectCount = 0}
}
newAPNSPushClient :: NtfPushServer -> APNSProvider -> IO PushProviderClient
newAPNSPushClient NtfPushServer {apnsConfig, pushClients} pp = do
case apnsProviderHost pp of
Nothing -> pure $ \_ _ -> pure ()
Just host -> apnsPushProviderClient <$> createAPNSPushClient host apnsConfig
getPushClient :: NtfPushServer -> PushProvider p -> IO (PushProviderClient p)
getPushClient s = \case
PPAPNS p -> TM.lookupIO p (apnsPushClients s) >>= maybe (newAPNSPushClient s p) pure
PPWP p -> TM.lookupIO p (webPushClients s) >>= maybe (newWPPushClient s p) pure
newWPPushClient :: NtfPushServer -> WPProvider -> IO PushProviderClient
newWPPushClient NtfPushServer {wpConfig, pushClients} pp = do
logDebug "New WP Client requested"
-- We use one http manager per push server (which may be used by different clients)
manager <- wpHTTPManager
cache <- newIORef Nothing
random <- C.newRandom
let client = WebPushClient {wpConfig, cache, manager, random}
pure $ wpPushProviderClient client
wpHTTPManager :: IO Manager
wpHTTPManager =
newManager
tlsManagerSettings
{ -- Ideally, we should be able to override the domain resolution to
-- disable requests to non-public IPs. The risk is very limited as
-- we allow https only, and the body is encrypted. Disabling redirections
-- avoids cross-protocol redir (https => http/unix)
managerModifyRequest = \r -> pure r {redirectCount = 0}
}
getPushClient :: NtfPushServer -> PushProvider -> IO PushProviderClient
getPushClient s@NtfPushServer {pushClients} pp =
TM.lookupIO pp pushClients >>= maybe (newPushClient s pp) pure
data NtfRequest
= NtfReqNew CorrId ANewNtfEntity
@@ -11,7 +11,7 @@
module Simplex.Messaging.Notifications.Server.Main where
import Control.Logger.Simple (setLogLevel)
import Control.Monad ((<$!>))
import Control.Monad (unless, void, (<$!>))
import qualified Data.ByteString.Char8 as B
import Data.Functor (($>))
import Data.Ini (lookupValue, readIniFile)
@@ -31,9 +31,10 @@ import Simplex.Messaging.Client (HostMode (..), NetworkConfig (..), ProtocolClie
import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Notifications.Protocol (NtfTokenId)
import Simplex.Messaging.Notifications.Server (runNtfServer, restoreServerLastNtfs)
import Simplex.Messaging.Notifications.Server (restoreServerLastNtfs, runNtfServer)
import Simplex.Messaging.Notifications.Server.Env (NtfServerConfig (..), defaultInactiveClientExpiration)
import Simplex.Messaging.Notifications.Server.Push.APNS (defaultAPNSPushClientConfig)
import Simplex.Messaging.Notifications.Server.Push.WebPush (VapidKey (..), WebPushConfig (..), mkVapid)
import Simplex.Messaging.Notifications.Server.Store (newNtfSTMStore)
import Simplex.Messaging.Notifications.Server.Store.Postgres (exportNtfDbStore, importNtfSTMStore, newNtfDbStore)
import Simplex.Messaging.Notifications.Server.StoreLog (readWriteNtfSTMStore)
@@ -55,6 +56,7 @@ import System.Directory (createDirectoryIfMissing, doesFileExist, renameFile)
import System.Exit (exitFailure)
import System.FilePath (combine)
import System.IO (BufferMode (..), hSetBuffering, stderr, stdout)
import System.Process (readCreateProcess, shell)
import Text.Read (readMaybe)
ntfServerCLI :: FilePath -> FilePath -> IO ()
@@ -146,6 +148,7 @@ ntfServerCLI cfgPath logPath =
clearDirIfExists logPath
createDirectoryIfMissing True cfgPath
createDirectoryIfMissing True logPath
_ <- genVapidKey vapidKeyPath
let x509cfg = defaultX509Config {commonName = fromMaybe ip fqdn, signAlgorithm}
fp <- createServerX509 cfgPath x509cfg
let host = fromMaybe (if ip == "127.0.0.1" then "<hostnames>" else ip) fqdn
@@ -212,11 +215,13 @@ ntfServerCLI cfgPath logPath =
hSetBuffering stdout LineBuffering
hSetBuffering stderr LineBuffering
fp <- checkSavedFingerprint cfgPath defaultX509Config
vapidKey@VapidKey {fp = vapidFp} <- getVapidKey vapidKeyPath
let host = either (const "<hostnames>") T.unpack $ lookupValue "TRANSPORT" "host" ini
port = T.unpack $ strictIni "TRANSPORT" "port" ini
cfg@NtfServerConfig {transports} = serverConfig
cfg@NtfServerConfig {transports} = serverConfig vapidKey
srv = ProtoServerWithAuth (NtfServer [THDomainName host] (if port == "443" then "" else port) (C.KeyHash fp)) Nothing
printServiceInfo serverVersion srv
B.putStrLn $ "VAPID: " <> vapidFp
printNtfServerConfig transports dbStoreConfig
runNtfServer cfg
where
@@ -230,7 +235,7 @@ ntfServerCLI cfgPath logPath =
confirmMigrations = MCYesUp,
deletedTTL = iniDeletedTTL ini
}
serverConfig =
serverConfig vapidKey =
NtfServerConfig
{ transports = iniTransports ini,
controlPort = either (const Nothing) (Just . T.unpack) $ lookupValue "TRANSPORT" "control_port" ini,
@@ -258,6 +263,11 @@ ntfServerCLI cfgPath logPath =
persistErrorInterval = 0 -- seconds
},
apnsConfig = defaultAPNSPushClientConfig,
wpConfig =
WebPushConfig
{ vapidKey,
paddedNtfLength = 3072
},
subsBatchSize = 900,
inactiveClientExpiration =
settingIsOn "INACTIVE_CLIENTS" "disconnect" ini
@@ -294,6 +304,7 @@ ntfServerCLI cfgPath logPath =
putStrLn $ "Error: both " <> storeLogFilePath <> " file and " <> B.unpack schema <> " schema are present (database: " <> B.unpack connstr <> ")."
putStrLn "Configure notification server storage."
exitFailure
vapidKeyPath = combine cfgPath "vapid.privkey"
printNtfServerConfig :: [(ServiceName, ASrvTransport, AddHTTP)] -> PostgresStoreCfg -> IO ()
printNtfServerConfig transports PostgresStoreCfg {dbOpts = DBOpts {connstr, schema}, dbStoreLogPath} = do
@@ -350,18 +361,21 @@ cliCommandP cfgPath logPath iniFile =
skipTokensP =
option
strParse
( long "skip-tokens"
<> help "Skip tokens during import"
<> value S.empty
)
( long "skip-tokens"
<> help "Skip tokens during import"
<> value S.empty
)
initP :: Parser InitOptions
initP = do
enableStoreLog <-
flag' False
flag'
False
( long "disable-store-log"
<> help "Disable store log for persistence (enabled by default)"
)
<|> flag True True
<|> flag
True
True
( long "store-log"
<> short 'l'
<> help "Enable store log for persistence (DEPRECATED, enabled by default)"
@@ -395,3 +409,19 @@ cliCommandP cfgPath logPath iniFile =
<> metavar "FQDN"
)
pure InitOptions {enableStoreLog, dbOptions, signAlgorithm, ip, fqdn}
genVapidKey :: FilePath -> IO VapidKey
genVapidKey file = do
cfgExists <- doesFileExist file
unless cfgExists $ run $ "openssl ecparam -name prime256v1 -genkey -noout -out " <> file
key <- C.readECPrivateKey file
pure $ mkVapid key
where
run cmd = void $ readCreateProcess (shell cmd) ""
getVapidKey :: FilePath -> IO VapidKey
getVapidKey file = do
cfgExists <- doesFileExist file
unless cfgExists $ error $ "VAPID key not found: " <> file
key <- C.readECPrivateKey file
pure $ mkVapid key
@@ -10,6 +10,8 @@
module Simplex.Messaging.Notifications.Server.Push where
import Control.Exception (Exception)
import Control.Monad.Except (ExceptT)
import Crypto.Hash.Algorithms (SHA256 (..))
import qualified Crypto.PubKey.ECC.ECDSA as EC
import qualified Crypto.PubKey.ECC.Types as ECT
@@ -28,24 +30,30 @@ import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text)
import Data.Time.Clock.System
import qualified Data.X509 as X
import GHC.Exception (SomeException)
import Network.HTTP.Types (Status)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Notifications.Protocol
import Simplex.Messaging.Notifications.Server.Store.Types (NtfTknRec)
import Simplex.Messaging.Parsers (defaultJSON)
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2ClientError)
import qualified Simplex.Messaging.Crypto as C
import Network.HTTP.Types (Status)
import Control.Exception (Exception)
import Simplex.Messaging.Notifications.Server.Store.Types (NtfTknRec)
import Control.Monad.Except (ExceptT)
data JWTHeader = JWTHeader
{ alg :: Text, -- key algorithm, ES256 for APNS
kid :: Text -- key ID
{ typ :: Text, -- "JWT"
alg :: Text, -- key algorithm, ES256 for APNS
kid :: Maybe Text -- key ID
}
deriving (Show)
mkJWTHeader :: Text -> Maybe Text -> JWTHeader
mkJWTHeader alg kid = JWTHeader {typ = "JWT", alg, kid}
data JWTClaims = JWTClaims
{ iss :: Text, -- issuer, team ID for APNS
iat :: Int64 -- issue time, seconds from epoch
{ iss :: Maybe Text, -- issuer, team ID for APNS
iat :: Maybe Int64, -- issue time, seconds from epoch for APNS
exp :: Maybe Int64, -- expired time, seconds from epoch for web push
aud :: Maybe Text, -- audience, for web push
sub :: Maybe Text -- subject, to be inform if there is an issue, for web push
}
deriving (Show)
@@ -55,7 +63,16 @@ data JWTToken = JWTToken JWTHeader JWTClaims
mkJWTToken :: JWTHeader -> Text -> IO JWTToken
mkJWTToken hdr iss = do
iat <- systemSeconds <$> getSystemTime
pure $ JWTToken hdr JWTClaims {iss, iat}
pure $ JWTToken hdr $ jwtClaims iat
where
jwtClaims iat =
JWTClaims
{ iss = Just iss,
iat = Just iat,
exp = Nothing,
aud = Nothing,
sub = Nothing
}
type SignedJWTToken = ByteString
@@ -63,15 +80,23 @@ $(JQ.deriveToJSON defaultJSON ''JWTHeader)
$(JQ.deriveToJSON defaultJSON ''JWTClaims)
signedJWTToken :: EC.PrivateKey -> JWTToken -> IO SignedJWTToken
signedJWTToken pk (JWTToken hdr claims) = do
signedJWTToken_ :: (EC.Signature -> ByteString) -> EC.PrivateKey -> JWTToken -> IO SignedJWTToken
signedJWTToken_ serialize pk (JWTToken hdr claims) = do
let hc = jwtEncode hdr <> "." <> jwtEncode claims
sig <- EC.sign pk SHA256 hc
pure $ hc <> "." <> serialize sig
pure $ hc <> "." <> U.encodeUnpadded (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]
signedJWTToken :: EC.PrivateKey -> JWTToken -> IO SignedJWTToken
signedJWTToken = signedJWTToken_ $ \sig ->
encodeASN1' DER [Start Sequence, IntVal (EC.sign_r sig), IntVal (EC.sign_s sig), End Sequence]
-- | Does it work with APNS ?
signedJWTTokenRaw :: EC.PrivateKey -> JWTToken -> IO SignedJWTToken
signedJWTTokenRaw = signedJWTToken_ $ \sig ->
C.encodeBigInt (EC.sign_r sig) <> C.encodeBigInt (EC.sign_s sig)
readECPrivateKey :: FilePath -> IO EC.PrivateKey
readECPrivateKey f = do
@@ -93,10 +118,11 @@ data PushProviderError
| PPTokenInvalid NTInvalidReason
| PPRetryLater
| PPPermanentError
| PPInvalidPusher
| PPWPInvalidUrl
| PPWPRemovedEndpoint
| PPWPRequestTooLong
| PPWPOtherError Text
| PPWPOtherError SomeException
deriving (Show, Exception)
type PushProviderClient p = NtfTknRec -> DeviceToken p -> PushNotification -> ExceptT PushProviderError IO ()
type PushProviderClient = NtfTknRec -> PushNotification -> ExceptT PushProviderError IO ()
@@ -1,7 +1,5 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
@@ -162,9 +160,9 @@ createAPNSPushClient :: HostName -> APNSPushClientConfig -> IO APNSPushClient
createAPNSPushClient apnsHost apnsCfg@APNSPushClientConfig {authKeyFileEnv, authKeyAlg, authKeyIdEnv, appTeamId} = do
https2Client <- newTVarIO Nothing
void $ connectHTTPS2 apnsHost apnsCfg https2Client
privateKey <- readECPrivateKey =<< getEnv authKeyFileEnv
privateKey <- C.readECPrivateKey =<< getEnv authKeyFileEnv
authKeyId <- T.pack <$> getEnv authKeyIdEnv
let jwtHeader = JWTHeader {alg = authKeyAlg, kid = authKeyId}
let jwtHeader = mkJWTHeader authKeyAlg (Just authKeyId)
jwtToken <- newTVarIO =<< mkApnsJWTToken appTeamId jwtHeader privateKey
nonceDrg <- C.newRandom
pure APNSPushClient {https2Client, privateKey, jwtHeader, jwtToken, nonceDrg, apnsHost, apnsCfg}
@@ -180,7 +178,8 @@ getApnsJWTToken APNSPushClient {apnsCfg = APNSPushClientConfig {appTeamId, token
atomically $ writeTVar jwtToken t
pure signedJWT'
where
jwtTokenAge (JWTToken _ JWTClaims {iat}) = subtract iat . systemSeconds <$> getSystemTime
jwtTokenAge (JWTToken _ JWTClaims {iat = Just iat}) = subtract iat . systemSeconds <$> getSystemTime
jwtTokenAge (JWTToken _ JWTClaims {iat = Nothing}) = pure maxBound :: IO Int64
mkApnsJWTToken :: Text -> JWTHeader -> EC.PrivateKey -> IO (JWTToken, SignedJWTToken)
mkApnsJWTToken appTeamId jwtHeader privateKey = do
@@ -257,8 +256,10 @@ data APNSErrorResponse = APNSErrorResponse {reason :: Text}
$(JQ.deriveFromJSON defaultJSON ''APNSErrorResponse)
apnsPushProviderClient :: APNSPushClient -> PushProviderClient 'APNS
apnsPushProviderClient c@APNSPushClient {nonceDrg, apnsCfg} tkn (APNSDeviceToken _ tknStr) pn = do
-- TODO [webpush] change type accept token components so it only allows APNS token
apnsPushProviderClient :: APNSPushClient -> PushProviderClient
apnsPushProviderClient _ NtfTknRec {token = WPDeviceToken _ _} _ = throwE PPInvalidPusher
apnsPushProviderClient c@APNSPushClient {nonceDrg, apnsCfg} tkn@NtfTknRec {token = APNSDeviceToken _ tknStr} pn = do
http2 <- liftHTTPS2 $ getApnsHTTP2Client c
nonce <- atomically $ C.randomCbNonce nonceDrg
apnsNtf <- liftEither $ first PPCryptoError $ apnsNotification tkn nonce (paddedNtfLength apnsCfg) pn
@@ -1,11 +1,10 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Use newtype instead of data" #-}
module Simplex.Messaging.Notifications.Server.Push.WebPush where
@@ -15,37 +14,139 @@ import Control.Logger.Simple (logDebug)
import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (throwE)
import qualified Crypto.Cipher.Types as CT
import Crypto.Hash.Algorithms (SHA256)
import qualified Crypto.MAC.HMAC as HMAC
import qualified Crypto.PubKey.ECC.DH as ECDH
import qualified Crypto.PubKey.ECC.ECDSA as ECDSA
import qualified Crypto.PubKey.ECC.Types as ECC
import Crypto.Random (MonadRandom(getRandomBytes))
import Crypto.Random (ChaChaDRG, getRandomBytes)
import Data.Aeson ((.=))
import qualified Data.Aeson as J
import qualified Data.Binary as Bin
import qualified Data.ByteArray as BA
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as BL
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Base64.URL as B64
import qualified Data.ByteString.Lazy as LB
import Data.IORef
import Data.Int (Int64)
import Data.Text (Text)
import qualified Data.Text.Encoding as T
import Data.Time.Clock.System (getSystemTime, systemSeconds)
import Network.HTTP.Client
import qualified Network.HTTP.Types as N
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), PushType (..), WPAuth (..), WPKey (..), WPTokenParams (..), WPP256dh (..), uncompressEncodePoint, wpRequest)
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfRegCode (..), WPAuth (..), WPKey (..), WPP256dh (..), WPTokenParams (..), encodePNMessages, wpAud, wpRequest)
import Simplex.Messaging.Notifications.Server.Push
import Simplex.Messaging.Util (liftError', tshow)
import Simplex.Messaging.Notifications.Server.Store.Types
import Simplex.Messaging.Util (liftError', safeDecodeUtf8, tshow)
import UnliftIO.STM
wpPushProviderClient :: Manager -> PushProviderClient 'WebPush
wpPushProviderClient mg _ t@(WPDeviceToken _ params) pn = do
-- | Vapid
-- | fp: fingerprint, base64url encoded without padding
-- | key: privkey
data VapidKey = VapidKey
{ key :: ECDSA.PrivateKey,
fp :: ByteString
}
deriving (Eq, Show)
mkVapid :: ECDSA.PrivateKey -> VapidKey
mkVapid key = VapidKey {key, fp}
where
fp = B64.encodeUnpadded $ C.uncompressEncodePoint $ ECDH.calculatePublic (ECC.getCurveByName ECC.SEC_p256r1) $ ECDSA.private_d key
data WebPushClient = WebPushClient
{ wpConfig :: WebPushConfig,
cache :: IORef (Maybe WPCache),
manager :: Manager,
random :: TVar ChaChaDRG
}
data WebPushConfig = WebPushConfig
{ vapidKey :: VapidKey,
paddedNtfLength :: Int
}
data WPCache = WPCache
{ vapidHeader :: ByteString,
expire :: Int64
}
getVapidHeader :: VapidKey -> IORef (Maybe WPCache) -> ByteString -> IO ByteString
getVapidHeader vapidK cache uriAuthority = do
h <- readIORef cache
now <- systemSeconds <$> getSystemTime
case h of
Nothing -> newCacheEntry now
-- if it expires in 1 min, then we renew - for safety
Just entry ->
if expire entry > now + 60
then pure $ vapidHeader entry
else newCacheEntry now
where
newCacheEntry :: Int64 -> IO ByteString
newCacheEntry now = do
-- The new entry expires in one hour
let expire = now + 3600
vapidHeader <- mkVapidHeader vapidK uriAuthority expire
let entry = Just WPCache {vapidHeader, expire}
atomicWriteIORef cache entry
pure vapidHeader
-- | With time in input for the tests
getVapidHeader' :: Int64 -> VapidKey -> IORef (Maybe WPCache) -> ByteString -> IO ByteString
getVapidHeader' now vapidK cache uriAuthority = do
h <- readIORef cache
case h of
Nothing -> newCacheEntry
Just entry ->
if expire entry > now
then pure $ vapidHeader entry
else newCacheEntry
where
newCacheEntry :: IO ByteString
newCacheEntry = do
-- The new entry expires in one hour
let expire = now + 3600
vapidHeader <- mkVapidHeader vapidK uriAuthority expire
let entry = Just WPCache {vapidHeader, expire}
atomicWriteIORef cache entry
pure vapidHeader
-- | mkVapidHeader -> vapid -> endpoint -> expire -> vapid header
mkVapidHeader :: VapidKey -> ByteString -> Int64 -> IO ByteString
mkVapidHeader VapidKey {key, fp} uriAuthority expire = do
let jwtHeader = mkJWTHeader "ES256" Nothing
jwtClaims =
JWTClaims
{ iss = Nothing,
iat = Nothing,
exp = Just expire,
aud = Just $ T.decodeUtf8 $ "https://" <> uriAuthority,
sub = Just "https://github.com/simplex-chat/simplexmq/"
}
jwt = JWTToken jwtHeader jwtClaims
signedToken <- signedJWTTokenRaw key jwt
pure $ "vapid t=" <> signedToken <> ",k=" <> fp
wpPushProviderClient :: WebPushClient -> PushProviderClient
wpPushProviderClient _ NtfTknRec {token = APNSDeviceToken _ _} _ = throwE PPInvalidPusher
wpPushProviderClient c@WebPushClient {wpConfig, cache, manager} tkn@NtfTknRec {token = token@(WPDeviceToken pp params)} pn = do
-- TODO [webpush] this function should accept type that is restricted to WP token (so, possibly WPProvider and WPTokenParams)
-- parsing will happen in DeviceToken parser, so it won't fail here
r <- wpRequest t
r <- wpRequest token
vapidH <- liftError' toPPWPError $ try $ getVapidHeader (vapidKey wpConfig) cache $ wpAud pp
logDebug $ "Web Push request to " <> tshow (host r)
encBody <- withExceptT PPCryptoError $ wpEncrypt (wpKey params) (BL.toStrict $ encodeWPN pn)
encBody <- withExceptT PPCryptoError $ wpEncrypt c tkn params pn
let requestHeaders =
[ ("TTL", "2592000"), -- 30 days
("Urgency", "high"),
("Content-Encoding", "aes128gcm")
-- TODO: topic for pings and interval
("Content-Encoding", "aes128gcm"),
("Authorization", vapidH)
-- TODO: topic for pings and interval
]
req =
r
@@ -54,22 +155,37 @@ wpPushProviderClient mg _ t@(WPDeviceToken _ params) pn = do
requestBody = RequestBodyBS encBody,
redirectCount = 0
}
void $ liftError' toPPWPError $ try $ httpNoBody req mg
void $ liftError' toPPWPError $ try $ httpNoBody req manager
-- | encrypt :: UA key -> clear -> cipher
-- | https://www.rfc-editor.org/rfc/rfc8291#section-3.4
wpEncrypt :: WPKey -> ByteString -> ExceptT C.CryptoError IO ByteString
wpEncrypt wpKey clearT = do
salt :: ByteString <- liftIO $ getRandomBytes 16
wpEncrypt :: WebPushClient -> NtfTknRec -> WPTokenParams -> PushNotification -> ExceptT C.CryptoError IO ByteString
wpEncrypt WebPushClient {wpConfig, random} NtfTknRec {tknDhSecret} params pn = do
salt <- liftIO $ getRandomBytes 16
asPrivK <- liftIO $ ECDH.generatePrivate $ ECC.getCurveByName ECC.SEC_p256r1
wpEncrypt' wpKey asPrivK salt clearT
pn' <-
LB.toStrict . J.encode <$> case pn of
PNVerification (NtfRegCode code) -> do
(nonce, code') <- encrypt code
pure $ J.object ["nonce" .= nonce, "verification" .= code']
PNMessage msgData -> do
(nonce, msgData') <- encrypt $ encodePNMessages msgData
pure $ J.object ["nonce" .= nonce, "message" .= msgData']
PNCheckMessages -> pure $ J.object ["checkMessages" .= True]
wpEncrypt' (wpKey params) asPrivK salt pn'
where
encrypt :: ByteString -> ExceptT C.CryptoError IO (C.CbNonce, Text)
encrypt ntfData = do
nonce <- atomically $ C.randomCbNonce random
encData <- liftEither $ C.cbEncrypt tknDhSecret nonce ntfData $ paddedNtfLength wpConfig
pure (nonce, safeDecodeUtf8 $ B64.encode encData)
-- | encrypt :: UA key -> AS key -> salt -> clear -> cipher
-- | https://www.rfc-editor.org/rfc/rfc8291#section-3.4
wpEncrypt' :: WPKey -> ECC.PrivateNumber -> ByteString -> ByteString -> ExceptT C.CryptoError IO ByteString
wpEncrypt' WPKey {wpAuth, wpP256dh = WPP256dh uaPubK} asPrivK salt clearT = do
let uaPubKS = uncompressEncodePoint $ uaPubK
let asPubKS = uncompressEncodePoint . ECDH.calculatePublic (ECC.getCurveByName ECC.SEC_p256r1) $ asPrivK
let uaPubKS = C.uncompressEncodePoint uaPubK
let asPubKS = C.uncompressEncodePoint $ ECDH.calculatePublic (ECC.getCurveByName ECC.SEC_p256r1) asPrivK
ecdhSecret = ECDH.getShared (ECC.getCurveByName ECC.SEC_p256r1) asPrivK uaPubK
prkKey = hmac (unWPAuth wpAuth) ecdhSecret
keyInfo = "WebPush: info\0" <> uaPubKS <> asPubKS
@@ -79,37 +195,26 @@ wpEncrypt' WPKey {wpAuth, wpP256dh = WPP256dh uaPubK} asPrivK salt clearT = do
cek = B.take 16 $ BA.convert $ hmac prk (cekInfo <> "\x01")
nonceInfo = "Content-Encoding: nonce\0" :: ByteString
nonce = B.take 12 $ BA.convert $ hmac prk (nonceInfo <> "\x01")
rs = BL.toStrict $ Bin.encode (4096 :: Bin.Word32) -- with RFC8291, it's ok to always use 4096 because there is only one single record and the final record can be smaller than rs (RFC8188)
idlen = BL.toStrict $ Bin.encode (65 :: Bin.Word8) -- with RFC8291, keyid is the pubkey, so always 65 bytes
rs = LB.toStrict $ Bin.encode (4096 :: Bin.Word32) -- with RFC8291, it's ok to always use 4096 because there is only one single record and the final record can be smaller than rs (RFC8188)
idlen = LB.toStrict $ Bin.encode (65 :: Bin.Word8) -- with RFC8291, keyid is the pubkey, so always 65 bytes
header = salt <> rs <> idlen <> asPubKS
iv <- ivFrom nonce
iv <- liftEither $ C.gcmIV nonce
-- The last record uses a padding delimiter octet set to the value 0x02
(C.AuthTag tag, cipherT) <- C.encryptAES128NoPad (C.Key cek) iv $ clearT <> "\x02"
(C.AuthTag (CT.AuthTag tag), cipherT) <- C.encryptAES128NoPad (C.Key cek) iv $ clearT <> "\x02"
-- Uncomment to see intermediate values, to compare with RFC8291 example
-- liftIO . print $ strEncode (BA.convert ecdhSecret :: ByteString)
-- liftIO . print . strEncode $ takeHM 32 prkKey
-- liftIO . print . strEncode $ B.take 32 $ BA.convert prkKey
-- liftIO . print $ strEncode cek
-- liftIO . print $ strEncode cipherT
pure $ header <> cipherT <> BA.convert tag
where
hmac k v = HMAC.hmac k v :: HMAC.HMAC SHA256
ivFrom :: ByteString -> ExceptT C.CryptoError IO C.GCMIV
ivFrom s = liftEither $ C.gcmIV s
encodeWPN :: PushNotification -> BL.ByteString
encodeWPN pn = J.encode $ case pn of
PNVerification code -> J.object ["verification" .= code]
-- This hack prevents sending unencrypted message metadata in notifications, as we do not use it in the client - it simply receives all messages on each notification.
-- If we decide to change it to pull model as used in iOS, we can change JSON key to "message" with any payload, as the current clients would interpret it as "checkMessages".
-- In this case an additional encryption layer would need to be added here, in the same way as with APNS notifications.
PNMessage _ -> J.object ["checkMessages" .= True]
PNCheckMessages -> J.object ["checkMessages" .= True]
toPPWPError :: SomeException -> PushProviderError
toPPWPError e = case fromException e of
Just (InvalidUrlException _ _) -> PPWPInvalidUrl
Just (HttpExceptionRequest _ (StatusCodeException resp _)) -> fromStatusCode (responseStatus resp) ("" :: String)
_ -> PPWPOtherError $ tshow e
Just (InvalidUrlException _ _) -> PPWPInvalidUrl
Just (HttpExceptionRequest _ (StatusCodeException resp _)) -> fromStatusCode (responseStatus resp) ("" :: String)
_ -> PPWPOtherError e
where
fromStatusCode status reason
| status == N.status200 = PPWPRemovedEndpoint
@@ -33,7 +33,7 @@ import Simplex.Messaging.Util (whenM, ($>>=))
data NtfSTMStore = NtfSTMStore
{ tokens :: TMap NtfTokenId NtfTknData,
-- multiple registrations exist to protect from malicious registrations if token is compromised
tokenRegistrations :: TMap ADeviceToken (TMap ByteString NtfTokenId),
tokenRegistrations :: TMap DeviceToken (TMap ByteString NtfTokenId),
subscriptions :: TMap NtfSubscriptionId NtfSubData,
tokenSubscriptions :: TMap NtfTokenId (TVar (Set NtfSubscriptionId)),
subscriptionLookup :: TMap SMPQueueNtf NtfSubscriptionId,
@@ -54,7 +54,7 @@ newNtfSTMStore = do
data NtfTknData = NtfTknData
{ ntfTknId :: NtfTokenId,
token :: ADeviceToken,
token :: DeviceToken,
tknStatus :: TVar NtfTknStatus,
tknVerifyKey :: NtfPublicAuthKey,
tknDhKeys :: C.KeyPairX25519,
@@ -172,7 +172,7 @@ updateTokenDate st db NtfTknRec {ntfTknId, tknUpdatedAt} = do
void $ DB.execute db "UPDATE tokens SET updated_at = ? WHERE token_id = ?" (ts, ntfTknId)
withLog "updateTokenDate" st $ \sl -> logUpdateTokenTime sl ntfTknId ts
type NtfTknRow = (NtfTokenId, APushProvider, Binary ByteString, NtfTknStatus, NtfPublicAuthKey, C.PrivateKeyX25519, C.DhSecretX25519, Binary ByteString, Word16, Maybe SystemDate)
type NtfTknRow = (NtfTokenId, PushProvider, Binary ByteString, NtfTknStatus, NtfPublicAuthKey, C.PrivateKeyX25519, C.DhSecretX25519, Binary ByteString, Word16, Maybe SystemDate)
ntfTknQuery :: Query
ntfTknQuery =
@@ -13,14 +13,14 @@ import Data.Maybe (fromMaybe)
import Data.Word (Word16)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (ADeviceToken, NtfRegCode, NtfSubStatus, NtfSubscriptionId, NtfTokenId, NtfTknStatus, SMPQueueNtf)
import Simplex.Messaging.Notifications.Protocol (DeviceToken, NtfRegCode, NtfSubStatus, NtfSubscriptionId, NtfTokenId, NtfTknStatus, SMPQueueNtf)
import Simplex.Messaging.Notifications.Server.Store (NtfSubData (..), NtfTknData (..))
import Simplex.Messaging.Protocol (NotifierId, NtfPrivateAuthKey, NtfPublicAuthKey)
import Simplex.Messaging.SystemTime
data NtfTknRec = NtfTknRec
{ ntfTknId :: NtfTokenId,
token :: ADeviceToken,
token :: DeviceToken,
tknStatus :: NtfTknStatus,
tknVerifyKey :: NtfPublicAuthKey,
tknDhPrivKey :: C.PrivateKeyX25519,
@@ -46,7 +46,7 @@ import System.IO
data NtfStoreLogRecord
= CreateToken NtfTknRec
| TokenStatus NtfTokenId NtfTknStatus
| UpdateToken NtfTokenId ADeviceToken NtfRegCode
| UpdateToken NtfTokenId DeviceToken NtfRegCode
| TokenCron NtfTokenId Word16
| DeleteToken NtfTokenId
| UpdateTokenTime NtfTokenId SystemDate
@@ -94,7 +94,7 @@ logCreateToken s = logNtfStoreRecord s . CreateToken
logTokenStatus :: StoreLog 'WriteMode -> NtfTokenId -> NtfTknStatus -> IO ()
logTokenStatus s tknId tknStatus = logNtfStoreRecord s $ TokenStatus tknId tknStatus
logUpdateToken :: StoreLog 'WriteMode -> NtfTokenId -> ADeviceToken -> NtfRegCode -> IO ()
logUpdateToken :: StoreLog 'WriteMode -> NtfTokenId -> DeviceToken -> NtfRegCode -> IO ()
logUpdateToken s tknId token regCode = logNtfStoreRecord s $ UpdateToken tknId token regCode
logTokenCron :: StoreLog 'WriteMode -> NtfTokenId -> Word16 -> IO ()
+2 -2
View File
@@ -42,7 +42,7 @@ instance FromField NtfTknAction where fromField = blobFieldDecoder smpDecode
instance ToField NtfTknAction where toField = toField . Binary . smpEncode
data NtfToken = NtfToken
{ deviceToken :: ADeviceToken,
{ deviceToken :: DeviceToken,
ntfServer :: NtfServer,
ntfTokenId :: Maybe NtfTokenId,
-- TODO combine keys to key pair as the types should match
@@ -63,7 +63,7 @@ data NtfToken = NtfToken
}
deriving (Show)
newNtfToken :: ADeviceToken -> NtfServer -> C.AAuthKeyPair -> C.KeyPairX25519 -> NotificationsMode -> NtfToken
newNtfToken :: DeviceToken -> NtfServer -> C.AAuthKeyPair -> C.KeyPairX25519 -> NotificationsMode -> NtfToken
newNtfToken deviceToken ntfServer (ntfPubKey, ntfPrivKey) ntfDhKeys ntfMode =
NtfToken
{ deviceToken,
+51 -43
View File
@@ -196,17 +196,18 @@ testNtfMatrix ps@(_, msType) runTest = do
cfg' = cfgMS msType
cfgVPrev' = cfgVPrev msType
registerNtfToken :: AgentClient -> ADeviceToken -> NotificationsMode -> AE NtfTknStatus
registerNtfToken :: AgentClient -> DeviceToken -> NotificationsMode -> AE NtfTknStatus
registerNtfToken c = A.registerNtfToken c NRMInteractive
checkNtfToken :: AgentClient -> ADeviceToken -> AE NtfTknStatus
checkNtfToken :: AgentClient -> DeviceToken -> AE NtfTknStatus
checkNtfToken c = A.checkNtfToken c NRMInteractive
verifyNtfToken :: AgentClient -> ADeviceToken -> C.CbNonce -> ByteString -> AE ()
verifyNtfToken :: AgentClient -> DeviceToken -> C.CbNonce -> ByteString -> AE ()
verifyNtfToken c = A.verifyNtfToken c NRMInteractive
runNtfTestCfg :: HasCallStack => (ASrvTransport, AStoreType) -> AgentMsgId -> AServerConfig -> NtfServerConfig -> AgentConfig -> AgentConfig -> (APNSMockServer -> AgentMsgId -> AgentClient -> AgentClient -> IO ()) -> IO ()
runNtfTestCfg (t, msType) baseId smpCfg ntfCfg aCfg bCfg runTest = do
runNtfTestCfg :: HasCallStack => (ASrvTransport, AStoreType) -> AgentMsgId -> AServerConfig -> IO NtfServerConfig -> AgentConfig -> AgentConfig -> (APNSMockServer -> AgentMsgId -> AgentClient -> AgentClient -> IO ()) -> IO ()
runNtfTestCfg (t, msType) baseId smpCfg ntfCfg' aCfg bCfg runTest = do
ntfCfg <- ntfCfg'
ASSCfg qt mt serverStoreCfg <- pure $ testServerStoreConfig msType
let smpCfg' = withServerCfg smpCfg $ \cfg_ -> ASrvCfg qt mt cfg_ {serverStoreCfg}
withSmpServerConfigOn t smpCfg' testPort $ \_ ->
@@ -218,7 +219,7 @@ runNtfTestCfg (t, msType) baseId smpCfg ntfCfg aCfg bCfg runTest = do
testNotificationToken :: APNSMockServer -> IO ()
testNotificationToken apns = do
withAgent 1 agentCfg initAgentServers testDB $ \a -> runRight_ $ do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
@@ -242,7 +243,7 @@ v .-> key = do
testNtfTokenRepeatRegistration :: APNSMockServer -> IO ()
testNtfTokenRepeatRegistration apns = do
withAgent 1 agentCfg initAgentServers testDB $ \a -> runRight_ $ do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
@@ -261,7 +262,7 @@ testNtfTokenRepeatRegistration apns = do
testNtfTokenSecondRegistration :: APNSMockServer -> IO ()
testNtfTokenSecondRegistration apns =
withAgentClients2 $ \a a' -> runRight_ $ do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
@@ -290,7 +291,7 @@ testNtfTokenSecondRegistration apns =
testNtfTokenServerRestart :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestart t apns = do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
ntfData <- withAgent 1 agentCfg initAgentServers testDB $ \a ->
withNtfServer t $ runRight $ do
NTRegistered <- registerNtfToken a tkn NMPeriodic
@@ -311,7 +312,7 @@ testNtfTokenServerRestart t apns = do
testNtfTokenServerRestartReverify :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestartReverify t apns = do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAgent 1 agentCfg initAgentServers testDB $ \a -> do
ntfData <- withNtfServer t $ runRight $ do
NTRegistered <- registerNtfToken a tkn NMPeriodic
@@ -334,7 +335,7 @@ testNtfTokenServerRestartReverify t apns = do
testNtfTokenServerRestartReverifyTimeout :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestartReverifyTimeout t apns = do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAgent 1 agentCfg initAgentServers testDB $ \a@AgentClient {agentEnv = Env {store}} -> do
(nonce, verification) <- withNtfServer t $ runRight $ do
NTRegistered <- registerNtfToken a tkn NMPeriodic
@@ -355,7 +356,7 @@ testNtfTokenServerRestartReverifyTimeout t apns = do
SET tkn_status = ?, tkn_action = ?
WHERE provider = ? AND device_token = ?
|]
(NTConfirmed, Just (NTAVerify code), APP SAPNS (PPAPNS PPApnsTest), "abcd" :: ByteString)
(NTConfirmed, Just (NTAVerify code), PPAPNS PPApnsTest, "abcd" :: ByteString)
Just NtfToken {ntfTknStatus = NTConfirmed, ntfTknAction = Just (NTAVerify _)} <- withTransaction store getSavedNtfToken
pure ()
threadDelay 1500000
@@ -369,7 +370,7 @@ testNtfTokenServerRestartReverifyTimeout t apns = do
testNtfTokenServerRestartReregister :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestartReregister t apns = do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAgent 1 agentCfg initAgentServers testDB $ \a ->
withNtfServer t $ runRight $ do
NTRegistered <- registerNtfToken a tkn NMPeriodic
@@ -393,7 +394,7 @@ testNtfTokenServerRestartReregister t apns = do
testNtfTokenServerRestartReregisterTimeout :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestartReregisterTimeout t apns = do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAgent 1 agentCfg initAgentServers testDB $ \a@AgentClient {agentEnv = Env {store}} -> do
withNtfServer t $ runRight $ do
NTRegistered <- registerNtfToken a tkn NMPeriodic
@@ -409,7 +410,7 @@ testNtfTokenServerRestartReregisterTimeout t apns = do
SET tkn_id = NULL, tkn_dh_secret = NULL, tkn_status = ?, tkn_action = ?
WHERE provider = ? AND device_token = ?
|]
(NTNew, Just NTARegister, APP SAPNS (PPAPNS PPApnsTest), "abcd" :: ByteString)
(NTNew, Just NTARegister, PPAPNS PPApnsTest, "abcd" :: ByteString)
Just NtfToken {ntfTokenId = Nothing, ntfTknStatus = NTNew, ntfTknAction = Just NTARegister} <- withTransaction store getSavedNtfToken
pure ()
threadDelay 1000000
@@ -434,7 +435,7 @@ getTestNtfTokenPort a =
testNtfTokenMultipleServers :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenMultipleServers t apns = do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAgent 1 agentCfg initAgentServers2 testDB $ \a ->
withNtfServerThreadOn t ntfTestPort ntfTestDBCfg $ \ntf ->
withNtfServerThreadOn t ntfTestPort2 ntfTestDBCfg2 $ \ntf2 -> runRight_ $ do
@@ -554,7 +555,7 @@ testNotificationSubscriptionExistingConnection apns baseId alice@AgentClient {ag
get alice ##> ("", bobId, CON)
get bob ##> ("", aliceId, CON)
-- register notification token
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
NTRegistered <- registerNtfToken alice tkn NMInstant
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
@@ -607,9 +608,9 @@ testNotificationSubscriptionNewConnection :: HasCallStack => APNSMockServer -> A
testNotificationSubscriptionNewConnection apns baseId alice bob =
runRight_ $ do
-- alice registers notification token
ADT _ APNSDeviceToken {} <- registerTestToken alice "abcd" NMInstant apns
APNSDeviceToken {} <- registerTestToken alice "abcd" NMInstant apns
-- bob registers notification token
ADT _ APNSDeviceToken {} <- registerTestToken bob "bcde" NMInstant apns
APNSDeviceToken {} <- registerTestToken bob "bcde" NMInstant apns
-- establish connection
liftIO $ threadDelay 50000
(bobId, qInfo) <- createConnection alice 1 True SCMInvitation Nothing SMSubscribe
@@ -643,9 +644,9 @@ testNotificationSubscriptionNewConnection apns baseId alice bob =
where
msgId = subtract baseId
registerTestToken :: AgentClient -> ByteString -> NotificationsMode -> APNSMockServer -> ExceptT AgentErrorType IO ADeviceToken
registerTestToken :: AgentClient -> ByteString -> NotificationsMode -> APNSMockServer -> ExceptT AgentErrorType IO DeviceToken
registerTestToken a token mode apns = do
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest token
let tkn = APNSDeviceToken PPApnsTest token
NTRegistered <- registerNtfToken a tkn mode
Just APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData'}} <-
timeout 1000000 $ getMockNotification apns tkn
@@ -931,7 +932,8 @@ testMigrateToServiceSubscriptions :: HasCallStack => (ASrvTransport, AStoreType)
testMigrateToServiceSubscriptions ps@(t, msType) = withAgentClients2 $ \a b -> do
(c1, c2, c3) <- withSmpServerConfigOn t cfgNoService testPort $ \_ -> do
(c1, c2) <- withAPNSMockServer $ \apns -> do
withNtfServerCfg ntfCfgNoService $ \_ -> runRight $ do
cfg' <- ntfCfgNoService
withNtfServerCfg cfg' $ \_ -> runRight $ do
_tkn <- registerTestToken a "abcd" NMInstant apns
-- create 2 connections with ntfs, test delivery
c1 <- testConnectMsg apns a b "hello"
@@ -970,27 +972,31 @@ testMigrateToServiceSubscriptions ps@(t, msType) = withAgentClients2 $ \a b -> d
serverDOWN a b 5
-- Ntf server does not use server, subscriptions downgrade
c6 <- withAPNSMockServer $ \apns -> withSmpServer ps $ withNtfServerCfg ntfCfgNoService $ \_ -> do
serverUP a b 5
runRight $ do
testSendMsg apns a b c1 "msg 1"
testSendMsg apns a b c2 "msg 2"
testSendMsg apns a b c3 "msg 3"
testSendMsg apns a b c4 "msg 4"
testSendMsg apns a b c5 "msg 5"
testConnectMsg apns a b "msg 6"
c6 <- withAPNSMockServer $ \apns -> do
cfg' <- ntfCfgNoService
withSmpServer ps $ withNtfServerCfg cfg' $ \_ -> do
serverUP a b 5
runRight $ do
testSendMsg apns a b c1 "msg 1"
testSendMsg apns a b c2 "msg 2"
testSendMsg apns a b c3 "msg 3"
testSendMsg apns a b c4 "msg 4"
testSendMsg apns a b c5 "msg 5"
testConnectMsg apns a b "msg 6"
serverDOWN a b 6
withAPNSMockServer $ \apns -> withSmpServerConfigOn t cfgNoService testPort $ \_ -> withNtfServerCfg ntfCfgNoService $ \_ -> do
serverUP a b 6
runRight_ $ do
testSendMsg apns a b c1 "1"
testSendMsg apns a b c2 "2"
testSendMsg apns a b c3 "3"
testSendMsg apns a b c4 "4"
testSendMsg apns a b c5 "5"
testSendMsg apns a b c6 "6"
void $ testConnectMsg apns a b "7"
withAPNSMockServer $ \apns -> do
cfg' <- ntfCfgNoService
withSmpServerConfigOn t cfgNoService testPort $ \_ -> withNtfServerCfg cfg' $ \_ -> do
serverUP a b 6
runRight_ $ do
testSendMsg apns a b c1 "1"
testSendMsg apns a b c2 "2"
testSendMsg apns a b c3 "3"
testSendMsg apns a b c4 "4"
testSendMsg apns a b c5 "5"
testSendMsg apns a b c6 "6"
void $ testConnectMsg apns a b "7"
serverDOWN a b 7
where
testConnectMsg apns a b msg = do
@@ -1013,7 +1019,9 @@ testMigrateToServiceSubscriptions ps@(t, msType) = withAgentClients2 $ \a b -> d
cfgNoService = updateCfg (cfgMS msType) $ \(cfg' :: ServerConfig s) ->
let ServerConfig {transportConfig} = cfg'
in cfg' {transportConfig = transportConfig {askClientCert = False}} :: ServerConfig s
ntfCfgNoService = ntfServerCfg {useServiceCreds = False, transports = [(ntfTestPort, t, False)]}
ntfCfgNoService = do
cfg' <- ntfServerCfg
pure cfg' {useServiceCreds = False, transports = [(ntfTestPort, t, False)]}
testMessage_ :: HasCallStack => APNSMockServer -> AgentClient -> ConnId -> AgentClient -> ConnId -> SMP.MsgBody -> ExceptT AgentErrorType IO ()
testMessage_ apns a aId b bId msg = do
@@ -1023,7 +1031,7 @@ testMessage_ apns a aId b bId msg = do
get a =##> \case ("", c, Msg msg') -> c == bId && msg == msg'; _ -> False
ackMessage a bId msgId Nothing
messageNotification :: HasCallStack => APNSMockServer -> ADeviceToken -> ExceptT AgentErrorType IO (C.CbNonce, ByteString)
messageNotification :: HasCallStack => APNSMockServer -> DeviceToken -> ExceptT AgentErrorType IO (C.CbNonce, ByteString)
messageNotification apns tkn = do
500000 `timeout` getMockNotification apns tkn >>= \case
Nothing -> error "no notification"
+55 -45
View File
@@ -3,7 +3,6 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedLists #-}
@@ -17,6 +16,7 @@
module NtfClient where
import Control.Concurrent.STM (retry)
import Control.Exception (throwIO)
import Control.Monad
import Control.Monad.Except (runExceptT)
import Control.Monad.IO.Class
@@ -42,11 +42,13 @@ import Simplex.Messaging.Client (ProtocolClientConfig (..), chooseTransportHost,
import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClientAgentConfig)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Notifications.Protocol (ADeviceToken (..), DeviceToken (..), NtfResponse)
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfResponse)
import Simplex.Messaging.Notifications.Server (runNtfServerBlocking)
import Simplex.Messaging.Notifications.Server.Env
import Simplex.Messaging.Notifications.Server.Main (getVapidKey)
import Simplex.Messaging.Notifications.Server.Push.APNS
import Simplex.Messaging.Notifications.Server.Push.APNS.Internal
import Simplex.Messaging.Notifications.Server.Push.WebPush (WebPushConfig (..))
import Simplex.Messaging.Notifications.Transport
import Simplex.Messaging.Protocol
import Simplex.Messaging.Server.QueueStore.Postgres.Config (PostgresStoreCfg (..))
@@ -61,7 +63,6 @@ import UnliftIO.Async
import UnliftIO.Concurrent
import qualified UnliftIO.Exception as E
import UnliftIO.STM
import Control.Exception (throwIO)
testHost :: NonEmpty TransportHost
testHost = "localhost"
@@ -126,55 +127,64 @@ testNtfClient client = do
Right th -> client th
Left e -> error $ show e
ntfServerCfg :: NtfServerConfig
ntfServerCfg =
NtfServerConfig
{ transports = [],
controlPort = Nothing,
controlPortUserAuth = Nothing,
controlPortAdminAuth = Nothing,
subIdBytes = 24,
regCodeBytes = 32,
clientQSize = 2,
pushQSize = 2,
smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 0},
apnsConfig =
defaultAPNSPushClientConfig
{ apnsPort = apnsTestPort,
caStoreFile = "tests/fixtures/ca.crt"
},
subsBatchSize = 900,
inactiveClientExpiration = Just defaultInactiveClientExpiration,
dbStoreConfig = ntfTestDBCfg,
ntfCredentials = ntfTestServerCredentials,
useServiceCreds = True,
periodicNtfsInterval = 1,
-- stats config
logStatsInterval = Nothing,
logStatsStartTime = 0,
serverStatsLogFile = "tests/ntf-server-stats.daily.log",
serverStatsBackupFile = Nothing,
prometheusInterval = Nothing,
prometheusMetricsFile = ntfTestPrometheusMetricsFile,
ntfServerVRange = supportedServerNTFVRange,
transportConfig = mkTransportServerConfig True (Just alpnSupportedNTFHandshakes) False,
startOptions = defaultStartOptions
}
ntfServerCfg :: IO NtfServerConfig
ntfServerCfg = do
vapidKey <- getVapidKey "tests/fixtures/vapid.privkey"
pure
NtfServerConfig
{ transports = [],
controlPort = Nothing,
controlPortUserAuth = Nothing,
controlPortAdminAuth = Nothing,
subIdBytes = 24,
regCodeBytes = 32,
clientQSize = 2,
pushQSize = 2,
smpAgentCfg = defaultSMPClientAgentConfig {persistErrorInterval = 0},
apnsConfig =
defaultAPNSPushClientConfig
{ apnsPort = apnsTestPort,
caStoreFile = "tests/fixtures/ca.crt"
},
wpConfig = WebPushConfig {vapidKey, paddedNtfLength = 3072},
subsBatchSize = 900,
inactiveClientExpiration = Just defaultInactiveClientExpiration,
dbStoreConfig = ntfTestDBCfg,
ntfCredentials = ntfTestServerCredentials,
useServiceCreds = True,
periodicNtfsInterval = 1,
-- stats config
logStatsInterval = Nothing,
logStatsStartTime = 0,
serverStatsLogFile = "tests/ntf-server-stats.daily.log",
serverStatsBackupFile = Nothing,
prometheusInterval = Nothing,
prometheusMetricsFile = ntfTestPrometheusMetricsFile,
ntfServerVRange = supportedServerNTFVRange,
transportConfig = mkTransportServerConfig True (Just alpnSupportedNTFHandshakes) False,
startOptions = defaultStartOptions
}
ntfServerCfgVPrev :: NtfServerConfig
ntfServerCfgVPrev :: IO NtfServerConfig
ntfServerCfgVPrev =
ntfServerCfg
{ ntfServerVRange = prevRange $ ntfServerVRange ntfServerCfg,
>>= \cfg -> pure $ ntfServerCfgVPrev' cfg
ntfServerCfgVPrev' :: NtfServerConfig -> NtfServerConfig
ntfServerCfgVPrev' cfg =
cfg
{ ntfServerVRange = prevRange $ ntfServerVRange cfg,
smpAgentCfg = smpAgentCfg' {smpCfg = smpCfg' {serverVRange = prevRange serverVRange'}}
}
where
smpAgentCfg' = smpAgentCfg ntfServerCfg
smpAgentCfg' = smpAgentCfg cfg
smpCfg' = smpCfg smpAgentCfg'
serverVRange' = serverVRange smpCfg'
withNtfServerThreadOn :: HasCallStack => ASrvTransport -> ServiceName -> PostgresStoreCfg -> (HasCallStack => ThreadId -> IO a) -> IO a
withNtfServerThreadOn t port' dbStoreConfig =
withNtfServerCfg ntfServerCfg {transports = [(port', t, False)], dbStoreConfig}
withNtfServerThreadOn t port' dbStoreConfig a =
ntfServerCfg >>= \cfg ->
withNtfServerCfg cfg {transports = [(port', t, False)], dbStoreConfig} a
withNtfServerCfg :: HasCallStack => NtfServerConfig -> (ThreadId -> IO a) -> IO a
withNtfServerCfg cfg@NtfServerConfig {transports} =
@@ -294,9 +304,9 @@ getAPNSMockServer config@HTTP2ServerConfig {qSize} = do
putStrLn $ "runAPNSMockServer J.decodeStrict' error, reqBody: " <> show bodyHead
sendApnsResponse $ APNSRespError N.badRequest400 "bad_request_body"
getMockNotification :: MonadIO m => APNSMockServer -> ADeviceToken -> m APNSMockRequest
getMockNotification _ (ADT _ WPDeviceToken {}) = liftIO . throwIO $ userError "Invalid pusher"
getMockNotification APNSMockServer {notifications} (ADT _ (APNSDeviceToken _ token)) = do
getMockNotification :: MonadIO m => APNSMockServer -> DeviceToken -> m APNSMockRequest
getMockNotification _ (WPDeviceToken _ _) = liftIO . throwIO $ userError "Invalid pusher"
getMockNotification APNSMockServer {notifications} (APNSDeviceToken _ token) = do
atomically $ TM.lookup token notifications >>= maybe retry readTBQueue
getAnyMockNotification :: MonadIO m => APNSMockServer -> m APNSMockRequest
+3 -3
View File
@@ -107,7 +107,7 @@ testNotificationSubscription (ATransport t, msType) createQueue =
(nPub, nKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(tknPub, tknKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(dhPub, dhPriv :: C.PrivateKeyX25519) <- atomically $ C.generateKeyPair g
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAPNSMockServer $ \apns ->
smpTest2 t msType $ \rh sh ->
ntfTest t $ \nh -> do
@@ -160,7 +160,7 @@ testNotificationSubscription (ATransport t, msType) createQueue =
(msgBody, "hello") #== "delivered from queue"
Resp "6" _ OK <- signSendRecv rh rKey ("6", rId, ACK mId1)
-- replace token
let tkn' = ADT SAPNS $ APNSDeviceToken PPApnsTest "efgh"
let tkn' = APNSDeviceToken PPApnsTest "efgh"
RespNtf "7" tId' NROk <- signSendRecvNtf nh tknKey ("7", tId, TRPL tkn')
tId `shouldBe` tId'
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData2}} <-
@@ -237,7 +237,7 @@ registerToken nh apns token = do
g <- C.newRandom
(tknPub, tknKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(dhPub, dhPriv :: C.PrivateKeyX25519) <- atomically $ C.generateKeyPair g
let tkn = ADT SAPNS $ APNSDeviceToken PPApnsTest token
let tkn = APNSDeviceToken PPApnsTest token
RespNtf "1" NoEntity (NRTknId tId ntfDh) <- signSendRecvNtf nh tknKey ("1", NoEntity, TNEW $ NewNtfTkn tkn tknPub dhPub)
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
+45 -35
View File
@@ -1,30 +1,30 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE DataKinds #-}
module NtfWPTests where
import Control.Monad (unless)
import Control.Monad.Except (runExceptT)
import qualified Crypto.PubKey.ECC.Types as ECC
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Data.Either (isLeft)
import Data.IORef (newIORef)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String (StrEncoding (..))
import Simplex.Messaging.Notifications.Protocol
import Simplex.Messaging.Notifications.Server.Main (getVapidKey)
import Simplex.Messaging.Notifications.Server.Push.WebPush (getVapidHeader', wpEncrypt')
import Test.Hspec hiding (fit, it)
import Util
import Simplex.Messaging.Encoding.String (StrEncoding(..))
import qualified Data.ByteString as B
import qualified Crypto.PubKey.ECC.Types as ECC
import Simplex.Messaging.Notifications.Protocol
import Simplex.Messaging.Notifications.Server.Push.WebPush (wpEncrypt', encodeWPN)
import Control.Monad.Except (runExceptT)
import qualified Data.ByteString.Lazy as BL
import Simplex.Messaging.Notifications.Server.Push
import Data.List.NonEmpty (NonEmpty ((:|)))
import qualified Simplex.Messaging.Crypto as C
import Data.Time.Clock.System (SystemTime(..))
import Data.Either (isLeft)
ntfWPTests :: Spec
ntfWPTests = describe "NTF Protocol" $ do
it "decode WPDeviceToken from string" testWPDeviceTokenStrEncoding
it "decode invalid WPDeviceToken" testInvalidWPDeviceTokenStrEncoding
it "Encrypt RFC8291 example" testWPEncryption
it "PushNotifications encoding" testPNEncoding
it "Vapid header cache" testVapidCache
testWPDeviceTokenStrEncoding :: Expectation
testWPDeviceTokenStrEncoding = do
@@ -55,17 +55,17 @@ testInvalidWPDeviceTokenStrEncoding = do
-- e.g "https://#1" is a valid URL. But that is the same parser
-- we use to send the requests, so that's fine.
let ts = "webpush https://localhost:/ AQ3VfRX3_F38J3ltcmMVRg BKuw4WxupnnrZHqk6vCwoms4tOpitZMvFdR9eAn54yOPY4q9jpXOpl-Ui_FwbIy8ZbFCnuaS7RnO02ahuL4XxIM"
let t = strDecode ts :: Either String ADeviceToken
t = strDecode ts :: Either String DeviceToken
t `shouldSatisfy` isLeft
-- | Example from RFC8291
testWPEncryption :: Expectation
testWPEncryption = do
let clearT :: B.ByteString = "When I grow up, I want to be a watermelon"
let pParams :: WPTokenParams = either error id $ strDecode "/push/JzLQ3raZJfFBR0aqvOMsLrt54w4rJUsV BTBZMqHH6r4Tts7J_aSIgg BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
let salt :: B.ByteString = either error id $ strDecode "DGv6ra1nlYgDCS1FRnbzlw"
let privBS :: B.ByteString = either error id $ strDecode "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"
asPriv :: ECC.PrivateNumber <- case uncompressDecodePrivateNumber privBS of
let clearT :: ByteString = "When I grow up, I want to be a watermelon"
pParams :: WPTokenParams = either error id $ strDecode "/push/JzLQ3raZJfFBR0aqvOMsLrt54w4rJUsV BTBZMqHH6r4Tts7J_aSIgg BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
salt :: ByteString = either error id $ strDecode "DGv6ra1nlYgDCS1FRnbzlw"
privBS :: ByteString = either error id $ strDecode "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"
asPriv :: ECC.PrivateNumber <- case C.uncompressDecodePrivateNumber privBS of
Left e -> fail $ "Cannot decode PrivateNumber from b64 " <> show e
Right p -> pure p
mCip <- runExceptT $ wpEncrypt' (wpKey pParams) asPriv salt clearT
@@ -74,18 +74,28 @@ testWPEncryption = do
Right c -> pure c
strEncode cipher `shouldBe` "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
testPNEncoding :: Expectation
testPNEncoding = do
let pnVerif = PNVerification (NtfRegCode "abcd")
pnCheck = PNCheckMessages
pnMess = pnM "MyMessage"
enc pnCheck `shouldBe` "{\"checkMessages\":true}"
enc pnVerif `shouldBe` "{\"verification\":\"YWJjZA==\"}"
enc pnMess `shouldBe` "{\"checkMessages\":true}"
where
enc p = BL.toStrict $ encodeWPN p
pnM :: B.ByteString -> PushNotification
pnM m = do
let smpQ = either error id $ strDecode "smp://AAAA@l/AAAA"
let now = MkSystemTime 1761827386 0
PNMessage $ PNMessageData smpQ now (C.cbNonce "nonce") m :| []
testVapidCache :: Expectation
testVapidCache = do
let wpaud = "https://localhost"
let now = 1761900906
cache <- newIORef Nothing
vapidKey <- getVapidKey "tests/fixtures/vapid.privkey"
v1 <- getVapidHeader' now vapidKey cache wpaud
v2 <- getVapidHeader' now vapidKey cache wpaud
v1 `shouldBe` v2
-- we just don't test the signature here
v1 `shouldContainBS` "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3NjE5MDQ1MDYsImF1ZCI6Imh0dHBzOi8vbG9jYWxob3N0Iiwic3ViIjoiaHR0cHM6Ly9naXRodWIuY29tL3NpbXBsZXgtY2hhdC9zaW1wbGV4bXEvIn0."
v1 `shouldContainBS` ",k=BIk7ASkEr1A1rJRGXMKi77tAGj3dRouSgZdW6S5pee7a3h7fkvd0OYQixy4yj35UFZt8hd9TwAQiybDK_HJLwJA"
v3 <- getVapidHeader' (now + 3600) vapidKey cache wpaud
v1 `shouldNotBe` v3
v3 `shouldContainBS` "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9."
v3 `shouldContainBS` ",k=BIk7ASkEr1A1rJRGXMKi77tAGj3dRouSgZdW6S5pee7a3h7fkvd0OYQixy4yj35UFZt8hd9TwAQiybDK_HJLwJA"
shouldContainBS :: ByteString -> ByteString -> Expectation
shouldContainBS actual expected =
unless (expected `B.isInfixOf` actual) $
expectationFailure $
"Expected ByteString to contain:\n"
++ show expected
++ "\nBut got:\n"
++ show actual
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIMTAncBq2I7G3KvW4C8Y8Heg2cbcDTobbGFQFnBiA5M/oAoGCCqGSM49
AwEHoUQDQgAEiTsBKQSvUDWslEZcwqLvu0AaPd1Gi5KBl1bpLml57treHt+S93Q5
hCLHLjKPflQVm3yF31PABCLJsMr8ckvAkA==
-----END EC PRIVATE KEY-----