Compare commits

...
34 Commits
Author SHA1 Message Date
sim d46f9fc56c WIP: add test to check webpush requests from ntf server 2026-01-19 21:11:32 +00:00
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
Evgeny Poberezkin 1007deb1f5 refactor 2025-11-11 18:41:20 +00:00
Evgeny Poberezkin 17fe6edcde instance 2025-11-11 18:16:20 +00:00
Evgeny Poberezkin 4257255de2 fix test 2025-11-11 17:51:50 +00:00
Evgeny Poberezkin a4931289a7 remove function 2025-11-11 17:39:12 +00:00
Evgeny Poberezkin 673f50e0f4 remove unused error constructor 2025-11-11 17:15:23 +00:00
Evgeny Poberezkin 63c4647f39 refactor 2025-11-11 16:54:38 +00:00
Evgeny Poberezkin 7485175980 remove comment 2025-11-10 21:30:06 +00:00
Evgeny Poberezkin 8ee3fd0ef7 fix migration 2025-11-10 21:14:09 +00:00
S1m 5e28d4faba [webpush] Parsing during registration (#1661)
* Parse WPDeviceToken during registration

* Clarify PPInvalidPusher with apnsPushProviderClient

* Use SrvLoc for webpush endpoints

* Remove unused WPEndpoint

* Test RFC8291 - webpush encryption - implementation

* Fix tests with -fserver_postgres

* Disable redirections with webpush

* Rename webpush tests, and move behind server_postgres flag

* Parse webpush endpoint with StrEncoding

* Fix rename webpush tests

* Lint import

* Test push notification encoding for webpush

* Test strDecoding invalid WPDeviceToken
2025-11-10 20:45:16 +00:00
Evgeny Poberezkin d3d97685c3 Merge branch 'unified-push' into p1gp1g-webpush_requests 2025-11-10 20:23:08 +00:00
Evgeny Poberezkin f455705974 Merge branch 'master' into unified-push 2025-11-10 20:22:13 +00:00
Evgeny Poberezkin f5a8d8b21c token types and migration (WIP, does not compile) 2025-09-16 21:29:38 +01:00
Evgeny Poberezkin c56b04fb6b Merge branch 'webpush_requests' of github.com:p1gp1g/simplexmq into p1gp1g-webpush_requests 2025-09-16 19:07:56 +01:00
Evgeny Poberezkin cf38c129da Merge branch 'master' into unified-push 2025-09-16 19:02:53 +01:00
Evgeny 4a39c4a021 Merge branch 'unified-push' into webpush_requests 2025-08-29 10:34:03 +01:00
S1mandEvgeny 3e57dda015 ntf server: move token functions and types to shared module to be used with WebPush (#1611)
* Move generic push functions to Push.hs

* Fix move push

* minor changes

---------

Co-authored-by: Evgeny <evgeny@poberezkin.com>
2025-08-29 10:21:40 +01:00
sim 9d30af4edd Fix Urgency case 2025-08-27 12:47:33 +02:00
sim 720fb40a86 Encrypt wp notifications 2025-08-27 12:47:33 +02:00
sim 1295b28298 Lint liftPPWPError 2025-08-27 12:47:33 +02:00
sim 2205a1fb7c Use content of push notif with web push 2025-08-27 12:47:33 +02:00
sim a24024c0b8 Prepare webpush requests 2025-08-27 12:47:33 +02:00
sim 64269c212a Add WPDeviceToken 2025-08-27 12:47:33 +02:00
sim e7e7c9bfa1 Rename APNSDeviceToken 2025-08-27 12:47:19 +02:00
sim c08c3794ba Fix move push 2025-08-27 12:46:56 +02:00
sim e90c15bb90 Move generic push functions to Push.hs 2025-08-27 10:33:56 +02:00
21 changed files with 1212 additions and 297 deletions
+6
View File
@@ -264,7 +264,9 @@ library
Simplex.Messaging.Notifications.Server.Env
Simplex.Messaging.Notifications.Server.Main
Simplex.Messaging.Notifications.Server.Prometheus
Simplex.Messaging.Notifications.Server.Push
Simplex.Messaging.Notifications.Server.Push.APNS
Simplex.Messaging.Notifications.Server.Push.WebPush
Simplex.Messaging.Notifications.Server.Push.APNS.Internal
Simplex.Messaging.Notifications.Server.Stats
Simplex.Messaging.Notifications.Server.Store
@@ -297,6 +299,7 @@ library
, attoparsec ==0.14.*
, base >=4.14 && <5
, base64-bytestring >=1.0 && <1.3
, binary ==0.8.*
, composition ==1.0.*
, constraints >=0.12 && <0.14
, containers ==0.6.*
@@ -309,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.*
@@ -340,6 +344,7 @@ library
case-insensitive ==1.2.*
, hashable ==1.4.*
, ini ==0.4.1
, http-client-tls ==0.3.6.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, temporary ==1.3.*
@@ -509,6 +514,7 @@ test-suite simplexmq-test
AgentTests.NotificationTests
NtfClient
NtfServerTests
NtfWPTests
PostgresSchemaDump
hs-source-dirs:
tests
+1 -1
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 = DeviceToken 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
@@ -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 (DeviceToken (..), NtfSubscriptionId, NtfTknStatus (..), NtfTokenId, SMPQueueNtf (..))
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
@@ -1420,7 +1420,8 @@ deleteCommand db cmdId =
DB.execute db "DELETE FROM commands WHERE command_id = ?" (Only cmdId)
createNtfToken :: DB.Connection -> NtfToken -> IO ()
createNtfToken db NtfToken {deviceToken = DeviceToken provider token, ntfServer = srv@ProtocolServer {host, port}, ntfTokenId, ntfPubKey, ntfPrivKey, ntfDhKeys = (ntfDhPubKey, ntfDhPrivKey), ntfDhSecret, ntfTknStatus, ntfTknAction, ntfMode} = do
createNtfToken db NtfToken {deviceToken, ntfServer = srv@ProtocolServer {host, port}, ntfTokenId, ntfPubKey, ntfPrivKey, ntfDhKeys = (ntfDhPubKey, ntfDhPrivKey), ntfDhSecret, ntfTknStatus, ntfTknAction, ntfMode} = do
let (provider, token) = deviceTokenFields deviceToken
upsertNtfServer_ db srv
DB.execute
db
@@ -1447,10 +1448,12 @@ getSavedNtfToken db = do
let ntfServer = NtfServer host port keyHash
ntfDhKeys = (ntfDhPubKey, ntfDhPrivKey)
ntfMode = fromMaybe NMPeriodic ntfMode_
in NtfToken {deviceToken = DeviceToken provider dt, ntfServer, ntfTokenId, ntfPubKey, ntfPrivKey, ntfDhKeys, ntfDhSecret, ntfTknStatus, ntfTknAction, ntfMode}
deviceToken = deviceToken' provider dt
in NtfToken {deviceToken, ntfServer, ntfTokenId, ntfPubKey, ntfPrivKey, ntfDhKeys, ntfDhSecret, ntfTknStatus, ntfTknAction, ntfMode}
updateNtfTokenRegistration :: DB.Connection -> NtfToken -> NtfTokenId -> C.DhSecretX25519 -> IO ()
updateNtfTokenRegistration db NtfToken {deviceToken = DeviceToken provider token, ntfServer = ProtocolServer {host, port}} tknId ntfDhSecret = do
updateNtfTokenRegistration db NtfToken {deviceToken, ntfServer = ProtocolServer {host, port}} tknId ntfDhSecret = do
let (provider, token) = deviceTokenFields deviceToken
updatedAt <- getCurrentTime
DB.execute
db
@@ -1462,8 +1465,10 @@ updateNtfTokenRegistration db NtfToken {deviceToken = DeviceToken provider token
(tknId, ntfDhSecret, NTRegistered, Nothing :: Maybe NtfTknAction, updatedAt, provider, token, host, port)
updateDeviceToken :: DB.Connection -> NtfToken -> DeviceToken -> IO ()
updateDeviceToken db NtfToken {deviceToken = DeviceToken provider token, ntfServer = ProtocolServer {host, port}} (DeviceToken toProvider toToken) = do
updateDeviceToken db NtfToken {deviceToken, ntfServer = ProtocolServer {host, port}} toDt = do
let (provider, token) = deviceTokenFields deviceToken
updatedAt <- getCurrentTime
let (toProvider, toToken) = deviceTokenFields toDt
DB.execute
db
[sql|
@@ -1474,7 +1479,8 @@ updateDeviceToken db NtfToken {deviceToken = DeviceToken provider token, ntfServ
(toProvider, toToken, NTRegistered, Nothing :: Maybe NtfTknAction, updatedAt, provider, token, host, port)
updateNtfMode :: DB.Connection -> NtfToken -> NotificationsMode -> IO ()
updateNtfMode db NtfToken {deviceToken = DeviceToken provider token, ntfServer = ProtocolServer {host, port}} ntfMode = do
updateNtfMode db NtfToken {deviceToken, ntfServer = ProtocolServer {host, port}} ntfMode = do
let (provider, token) = deviceTokenFields deviceToken
updatedAt <- getCurrentTime
DB.execute
db
@@ -1486,7 +1492,8 @@ updateNtfMode db NtfToken {deviceToken = DeviceToken provider token, ntfServer =
(ntfMode, updatedAt, provider, token, host, port)
updateNtfToken :: DB.Connection -> NtfToken -> NtfTknStatus -> Maybe NtfTknAction -> IO ()
updateNtfToken db NtfToken {deviceToken = DeviceToken provider token, ntfServer = ProtocolServer {host, port}} tknStatus tknAction = do
updateNtfToken db NtfToken {deviceToken, ntfServer = ProtocolServer {host, port}} tknStatus tknAction = do
let (provider, token) = deviceTokenFields deviceToken
updatedAt <- getCurrentTime
DB.execute
db
@@ -1498,7 +1505,8 @@ updateNtfToken db NtfToken {deviceToken = DeviceToken provider token, ntfServer
(tknStatus, tknAction, updatedAt, provider, token, host, port)
removeNtfToken :: DB.Connection -> NtfToken -> IO ()
removeNtfToken db NtfToken {deviceToken = DeviceToken provider token, ntfServer = ProtocolServer {host, port}} =
removeNtfToken db NtfToken {deviceToken, ntfServer = ProtocolServer {host, port}} = do
let (provider, token) = deviceTokenFields deviceToken
DB.execute
db
[sql|
@@ -1823,7 +1831,8 @@ getActiveNtfToken db =
let ntfServer = NtfServer host port keyHash
ntfDhKeys = (ntfDhPubKey, ntfDhPrivKey)
ntfMode = fromMaybe NMPeriodic ntfMode_
in NtfToken {deviceToken = DeviceToken provider dt, ntfServer, ntfTokenId, ntfPubKey, ntfPrivKey, ntfDhKeys, ntfDhSecret, ntfTknStatus, ntfTknAction, ntfMode}
deviceToken = deviceToken' provider dt
in NtfToken {deviceToken, ntfServer, ntfTokenId, ntfPubKey, ntfPrivKey, ntfDhKeys, ntfDhSecret, ntfTknStatus, ntfTknAction, ntfMode}
getNtfRcvQueue :: DB.Connection -> SMPQueueNtf -> IO (Either StoreError (ConnId, Int64, RcvNtfDhSecret, Maybe UTCTime))
getNtfRcvQueue db SMPQueueNtf {smpServer = (SMPServer host port _), notifierId} =
+83 -9
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 (..),
@@ -128,6 +133,7 @@ module Simplex.Messaging.Crypto
encryptAEAD,
decryptAEAD,
encryptAESNoPad,
encryptAES128NoPad,
decryptAESNoPad,
authTagSize,
randomAesKey,
@@ -210,24 +216,29 @@ import Control.Exception (Exception)
import Control.Monad
import Control.Monad.Except
import Control.Monad.Trans.Except
import Crypto.Cipher.AES (AES256)
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)
@@ -235,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 (+))
@@ -1039,9 +1051,20 @@ encryptAESNoPad :: Key -> GCMIV -> ByteString -> ExceptT CryptoError IO (AuthTag
encryptAESNoPad key iv = encryptAEADNoPad key iv ""
{-# INLINE encryptAESNoPad #-}
-- Used to encrypt WebPush notifications
-- This function requires 12 bytes IV, it does not transform IV.
encryptAES128NoPad :: Key -> GCMIV -> ByteString -> ExceptT CryptoError IO (AuthTag, ByteString)
encryptAES128NoPad key iv = encryptAEAD128NoPad key iv ""
{-# INLINE encryptAES128NoPad #-}
encryptAEADNoPad :: Key -> GCMIV -> ByteString -> ByteString -> ExceptT CryptoError IO (AuthTag, ByteString)
encryptAEADNoPad aesKey ivBytes ad msg = do
aead <- initAEADGCM aesKey ivBytes
aead <- initAEADGCM @AES256 aesKey ivBytes
pure . first AuthTag $ AES.aeadSimpleEncrypt aead ad msg authTagSize
encryptAEAD128NoPad :: Key -> GCMIV -> ByteString -> ByteString -> ExceptT CryptoError IO (AuthTag, ByteString)
encryptAEAD128NoPad aesKey ivBytes ad msg = do
aead <- initAEADGCM @AES128 aesKey ivBytes
pure . first AuthTag $ AES.aeadSimpleEncrypt aead ad msg authTagSize
-- | AEAD-GCM decryption with associated data.
@@ -1063,7 +1086,7 @@ decryptAESNoPad key iv = decryptAEADNoPad key iv ""
decryptAEADNoPad :: Key -> GCMIV -> ByteString -> ByteString -> AuthTag -> ExceptT CryptoError IO ByteString
decryptAEADNoPad aesKey iv ad msg (AuthTag tag) = do
aead <- initAEADGCM aesKey iv
aead <- initAEADGCM @AES256 aesKey iv
maybeError AESDecryptError (AES.aeadSimpleDecrypt aead ad msg tag)
maxMsgLen :: Int
@@ -1138,7 +1161,7 @@ initAEAD (Key aesKey) (IV ivBytes) = do
AES.aeadInit AES.AEAD_GCM cipher iv
-- this function requires 12 bytes IV, it does not transforms IV.
initAEADGCM :: Key -> GCMIV -> ExceptT CryptoError IO (AES.AEAD AES256)
initAEADGCM :: forall c. AES.BlockCipher c => Key -> GCMIV -> ExceptT CryptoError IO (AES.AEAD c)
initAEADGCM (Key aesKey) (GCMIV ivBytes) = cryptoFailable $ do
cipher <- AES.cipherInit aesKey
AES.aeadInit AES.AEAD_GCM cipher ivBytes
@@ -1240,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
@@ -1530,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)
+205 -17
View File
@@ -12,6 +12,7 @@
module Simplex.Messaging.Notifications.Protocol where
import Control.Applicative (optional, (<|>))
import qualified Crypto.PubKey.ECC.Types as ECC
import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=))
import qualified Data.Aeson as J
import qualified Data.Aeson.Encoding as JE
@@ -27,6 +28,7 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock.System
import Data.Type.Equality
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
@@ -372,14 +374,35 @@ instance StrEncoding SMPQueueNtf where
notifierId <- A.char '/' *> strP
pure SMPQueueNtf {smpServer, notifierId}
data PushProvider
data PushProvider = PPAPNS APNSProvider | PPWP WPProvider
deriving (Eq, Ord, Show)
data APNSProvider
= PPApnsDev -- provider for Apple development environment
| PPApnsProd -- production environment, including TestFlight
| PPApnsTest -- used for tests, to use APNS mock server
| PPApnsNull -- used to test servers from the client - does not communicate with APNS
deriving (Eq, Ord, Show)
newtype WPSrvLoc = WPSrvLoc SrvLoc
deriving (Eq, Ord, Show)
newtype WPProvider = WPP WPSrvLoc
deriving (Eq, Ord, Show)
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 =
A.peekChar' >>= \case
'A' -> PPAPNS <$> smpP
_ -> PPWP <$> smpP
instance Encoding APNSProvider where
smpEncode = \case
PPApnsDev -> "AD"
PPApnsProd -> "AP"
@@ -391,9 +414,18 @@ instance Encoding PushProvider where
"AP" -> pure PPApnsProd
"AT" -> pure PPApnsTest
"AN" -> pure PPApnsNull
_ -> fail "bad PushProvider"
_ -> fail "bad APNSProvider"
instance StrEncoding PushProvider where
strEncode = \case
PPAPNS p -> strEncode p
PPWP p -> strEncode p
strP =
A.peekChar' >>= \case
'a' -> PPAPNS <$> strP
_ -> PPWP <$> strP
instance StrEncoding APNSProvider where
strEncode = \case
PPApnsDev -> "apns_dev"
PPApnsProd -> "apns_prod"
@@ -405,38 +437,194 @@ instance StrEncoding PushProvider where
"apns_prod" -> pure PPApnsProd
"apns_test" -> pure PPApnsTest
"apns_null" -> pure PPApnsNull
_ -> fail "bad PushProvider"
_ -> fail "bad APNSProvider"
instance Encoding WPSrvLoc where
smpEncode (WPSrvLoc srv) = smpEncode srv
smpP = WPSrvLoc <$> smpP
instance StrEncoding WPSrvLoc where
strEncode (WPSrvLoc srv) = "https://" <> strEncode srv
strP = WPSrvLoc <$> ("https://" *> strP)
instance Encoding WPProvider where
smpEncode (WPP srv) = "WP" <> smpEncode srv
smpP = WPP <$> ("WP" *> smpP)
instance StrEncoding WPProvider where
strEncode (WPP srv) = "webpush " <> strEncode srv
strP = WPP <$> ("webpush " *> strP)
instance FromField PushProvider where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8
instance ToField PushProvider where toField = toField . decodeLatin1 . strEncode
data DeviceToken = DeviceToken PushProvider ByteString
newtype WPAuth = WPAuth {unWPAuth :: ByteString} deriving (Eq, Ord, Show)
toWPAuth :: ByteString -> Either String WPAuth
toWPAuth s
| B.length s == 16 = Right $ WPAuth s
| otherwise = Left "bad WPAuth"
newtype WPP256dh = WPP256dh ECC.PublicPoint
deriving (Eq, Show)
-- This Ord instance for ECC point is quite arbitrary, it is needed because token is used as Map key
instance Ord WPP256dh where
compare (WPP256dh p1) (WPP256dh p2) = case (p1, p2) of
(ECC.PointO, ECC.PointO) -> EQ
(ECC.PointO, _) -> GT
(_, ECC.PointO) -> LT
(ECC.Point x1 y1, ECC.Point x2 y2) -> compare (x1, y1) (x2, y2)
data WPKey = WPKey
{ wpAuth :: WPAuth,
wpP256dh :: WPP256dh
}
deriving (Eq, Ord, Show)
uncompressEncode :: WPP256dh -> ByteString
uncompressEncode (WPP256dh p) = C.uncompressEncodePoint p
{-# INLINE uncompressEncode #-}
uncompressDecode :: ByteString -> Either String WPP256dh
uncompressDecode bs = WPP256dh <$> C.uncompressDecodePoint bs
{-# INLINE uncompressDecode #-}
data WPTokenParams = WPTokenParams
{ wpPath :: ByteString,
wpKey :: WPKey
}
deriving (Eq, Ord, Show)
instance Encoding WPAuth where
smpEncode = smpEncode . unWPAuth
smpP = toWPAuth <$?> smpP
instance StrEncoding WPAuth where
strEncode = strEncode . unWPAuth
strP = toWPAuth <$?> strP
instance Encoding WPP256dh where
smpEncode = smpEncode . uncompressEncode
{-# INLINE smpEncode #-}
smpP = uncompressDecode <$?> smpP
{-# INLINE smpP #-}
instance StrEncoding WPP256dh where
strEncode = strEncode . uncompressEncode
{-# INLINE strEncode #-}
strP = uncompressDecode <$?> strP
{-# INLINE strP #-}
instance Encoding WPKey where
smpEncode WPKey {wpAuth, wpP256dh} = smpEncode (wpAuth, wpP256dh)
smpP = do
wpAuth <- smpP
wpP256dh <- smpP
pure WPKey {wpAuth, wpP256dh}
instance StrEncoding WPKey where
strEncode WPKey {wpAuth, wpP256dh} = strEncode (wpAuth, wpP256dh)
strP = do
(wpAuth, wpP256dh) <- strP
pure WPKey {wpAuth, wpP256dh}
instance Encoding WPTokenParams where
smpEncode WPTokenParams {wpPath, wpKey} = smpEncode (wpPath, wpKey)
smpP = do
wpPath <- smpP
wpKey <- smpP
pure WPTokenParams {wpPath, wpKey}
instance StrEncoding WPTokenParams where
strEncode WPTokenParams {wpPath, wpKey} = wpPath <> " " <> strEncode wpKey
strP = do
wpPath <- A.takeWhile (/= ' ')
_ <- A.char ' '
wpKey <- strP
pure WPTokenParams {wpPath, wpKey}
data DeviceToken
= APNSDeviceToken APNSProvider ByteString
| WPDeviceToken WPProvider WPTokenParams
deriving (Eq, Ord, Show)
tokenPushProvider :: DeviceToken -> PushProvider
tokenPushProvider = \case
APNSDeviceToken pp _ -> PPAPNS pp
WPDeviceToken pp _ -> PPWP pp
instance Encoding DeviceToken where
smpEncode (DeviceToken p t) = smpEncode (p, t)
smpP = DeviceToken <$> smpP <*> smpP
smpEncode token = case token of
APNSDeviceToken p t -> smpEncode (p, t)
WPDeviceToken p t -> smpEncode (p, t)
smpP =
smpP >>= \case
PPAPNS p -> APNSDeviceToken p <$> smpP
PPWP p -> WPDeviceToken p <$> smpP
instance StrEncoding DeviceToken where
strEncode (DeviceToken p t) = strEncode p <> " " <> t
strP = nullToken <|> hexToken
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 = nullToken <|> deviceToken
where
nullToken = "apns_null test_ntf_token" $> DeviceToken PPApnsNull "test_ntf_token"
hexToken = DeviceToken <$> strP <* A.space <*> hexStringP
hexStringP =
nullToken = "apns_null test_ntf_token" $> APNSDeviceToken PPApnsNull "test_ntf_token"
deviceToken =
strP >>= \case
PPAPNS p -> APNSDeviceToken p <$> hexStringP
PPWP p -> do
t <- WPDeviceToken p <$> strP
_ <- wpRequest 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 where
toEncoding (DeviceToken pp t) = J.pairs $ "pushProvider" .= decodeLatin1 (strEncode pp) <> "token" .= decodeLatin1 t
toJSON (DeviceToken pp t) = J.object ["pushProvider" .= decodeLatin1 (strEncode pp), "token" .= decodeLatin1 t]
toEncoding token = case token of
APNSDeviceToken p t -> J.pairs $ "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)
-- 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 -> do
pp <- strDecode . encodeUtf8 <$?> o .: "pushProvider"
t <- encodeUtf8 <$> o .: "token"
pure $ DeviceToken pp t
parseJSON = J.withObject "DeviceToken" $ \o ->
(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 :: 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)
deviceToken' :: PushProvider -> ByteString -> DeviceToken
deviceToken' pp t = case pp of
PPAPNS p -> APNSDeviceToken p t
PPWP p -> WPDeviceToken p <$> either error id $ strDecode t
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
-- List of PNMessageData uses semicolon-separated encoding instead of strEncode,
-- because strEncode of NonEmpty list uses comma for separator,
+12 -9
View File
@@ -56,7 +56,7 @@ import Simplex.Messaging.Notifications.Protocol
import Simplex.Messaging.Notifications.Server.Control
import Simplex.Messaging.Notifications.Server.Env
import Simplex.Messaging.Notifications.Server.Prometheus
import Simplex.Messaging.Notifications.Server.Push.APNS (PushNotification (..), PushProviderError (..))
import Simplex.Messaging.Notifications.Server.Push (PushNotification(..), PushProviderError(..))
import Simplex.Messaging.Notifications.Server.Stats
import Simplex.Messaging.Notifications.Server.Store (NtfSTMStore, TokenNtfMessageRecord (..), stmStoreTokenLastNtf)
import Simplex.Messaging.Notifications.Server.Store.Postgres
@@ -629,18 +629,18 @@ showServer' = decodeLatin1 . strEncode . host
ntfPush :: NtfPushServer -> M ()
ntfPush s@NtfPushServer {pushQ} = forever $ do
(srvHost_, tkn@NtfTknRec {ntfTknId, token = t@(DeviceToken pp _), tknStatus}, ntf) <- atomically (readTBQueue pushQ)
liftIO $ logDebug $ "sending push notification to " <> T.pack (show pp)
(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 pp tkn 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 pp tkn 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 pp tkn ntf) >>= \case
liftIO (deliverNotification st tkn ntf) >>= \case
Left _ -> do
incNtfStatT t ntfFailed
liftIO $ mapM_ (`incServerStat` ntfFailedOwn stats) srvHost_
@@ -661,8 +661,8 @@ ntfPush s@NtfPushServer {pushQ} = forever $ do
checkActiveTkn status action
| status == NTActive = action
| otherwise = liftIO $ logError "bad notification token status"
deliverNotification :: NtfPostgresStore -> PushProvider -> NtfTknRec -> PushNotification -> IO (Either PushProviderError ())
deliverNotification st pp tkn@NtfTknRec {ntfTknId} ntf = do
deliverNotification :: NtfPostgresStore -> NtfTknRec -> PushNotification -> IO (Either PushProviderError ())
deliverNotification st tkn@NtfTknRec {ntfTknId, token} ntf = do
deliver <- getPushClient s pp
runExceptT (deliver tkn ntf) >>= \case
Right _ -> pure $ Right ()
@@ -675,7 +675,10 @@ 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
@@ -905,7 +908,7 @@ withNtfStore stAction continue = do
Right a -> continue a
incNtfStatT :: DeviceToken -> (NtfServerStats -> IORef Int) -> M ()
incNtfStatT (DeviceToken PPApnsNull _) _ = pure ()
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
@@ -25,7 +28,9 @@ import Simplex.Messaging.Client (ProtocolClientConfig (..))
import Simplex.Messaging.Client.Agent
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
@@ -57,6 +62,7 @@ data NtfServerConfig = NtfServerConfig
pushQSize :: Natural,
smpAgentCfg :: SMPClientAgentConfig,
apnsConfig :: APNSPushClientConfig,
wpConfig :: WebPushConfig,
subsBatchSize :: Int,
inactiveClientExpiration :: Maybe ExpirationConfig,
dbStoreConfig :: PostgresStoreCfg,
@@ -96,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
@@ -112,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
@@ -149,22 +155,50 @@ data SMPSubscriber = SMPSubscriber
data NtfPushServer = NtfPushServer
{ pushQ :: TBQueue (Maybe T.Text, NtfTknRec, PushNotification), -- Maybe Text is a hostname of "own" server
pushClients :: TMap PushProvider PushProviderClient,
apnsConfig :: APNSPushClientConfig
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
pushClients <- TM.emptyIO
pure NtfPushServer {pushQ, pushClients, apnsConfig}
pure NtfPushServer {pushQ, pushClients, apnsConfig, wpConfig}
newPushClient :: NtfPushServer -> PushProvider -> IO PushProviderClient
newPushClient NtfPushServer {apnsConfig, pushClients} pp = do
c <- case apnsProviderHost pp of
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
newAPNSPushClient :: NtfPushServer -> APNSProvider -> IO PushProviderClient
newAPNSPushClient NtfPushServer {apnsConfig, pushClients} pp = do
case apnsProviderHost pp of
Nothing -> pure $ \_ _ -> pure ()
Just host -> apnsPushProviderClient <$> createAPNSPushClient host apnsConfig
atomically $ TM.insert pp c pushClients
pure c
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 =
@@ -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
@@ -0,0 +1,128 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Use newtype instead of data" #-}
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
import qualified Crypto.Store.PKCS8 as PK
import Data.ASN1.BinaryEncoding (DER (..))
import Data.ASN1.Encoding
import Data.ASN1.Types
import Data.Aeson (ToJSON)
import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ
import qualified Data.ByteString.Base64.URL as U
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
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)
data JWTHeader = JWTHeader
{ 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 :: 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)
data JWTToken = JWTToken JWTHeader JWTClaims
deriving (Show)
mkJWTToken :: JWTHeader -> Text -> IO JWTToken
mkJWTToken hdr iss = do
iat <- systemSeconds <$> getSystemTime
pure $ JWTToken hdr $ jwtClaims iat
where
jwtClaims iat =
JWTClaims
{ iss = Just iss,
iat = Just iat,
exp = Nothing,
aud = Nothing,
sub = Nothing
}
type SignedJWTToken = ByteString
$(JQ.deriveToJSON defaultJSON ''JWTHeader)
$(JQ.deriveToJSON defaultJSON ''JWTClaims)
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 <> "." <> U.encodeUnpadded (serialize sig)
where
jwtEncode :: ToJSON a => a -> ByteString
jwtEncode = U.encodeUnpadded . LB.toStrict . J.encode
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
-- 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 EC.PrivateKey {private_curve = ECT.getCurveByName privkeyEC_name, private_d = privkeyEC_priv}
data PushNotification
= PNVerification NtfRegCode
| PNMessage (NonEmpty PNMessageData)
| -- | PNAlert Text
PNCheckMessages
deriving (Show)
data PushProviderError
= PPConnection HTTP2ClientError
| PPCryptoError C.CryptoError
| PPResponseError (Maybe Status) Text
| PPTokenInvalid NTInvalidReason
| PPRetryLater
| PPPermanentError
| PPInvalidPusher
| PPWPInvalidUrl
| PPWPRemovedEndpoint
| PPWPRequestTooLong
| PPWPOtherError SomeException
deriving (Show, Exception)
type PushProviderClient = NtfTknRec -> PushNotification -> ExceptT PushProviderError IO ()
@@ -10,20 +10,13 @@
module Simplex.Messaging.Notifications.Server.Push.APNS where
import Control.Exception (Exception)
import Control.Logger.Simple
import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class
import Control.Monad.Trans.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)
import qualified Crypto.Store.PKCS8 as PK
import Data.ASN1.BinaryEncoding (DER (..))
import Data.ASN1.Encoding
import Data.ASN1.Types
import Data.Aeson (ToJSON, (.=))
import qualified Data.Aeson as J
import qualified Data.Aeson.Encoding as JE
@@ -32,18 +25,15 @@ 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.List (find)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Map.Strict (Map)
import Data.Maybe (isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock.System
import qualified Data.X509 as X
import qualified Data.X509.CertificateStore as XS
import Network.HPACK.Token as HT
import Network.HTTP.Types (Status)
@@ -53,6 +43,7 @@ import qualified Network.HTTP2.Client as H
import Network.Socket (HostName, ServiceName)
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.Internal
import Simplex.Messaging.Notifications.Server.Store.Types (NtfTknRec (..))
import Simplex.Messaging.Parsers (defaultJSON)
@@ -62,55 +53,6 @@ import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
import System.Environment (getEnv)
import UnliftIO.STM
data JWTHeader = JWTHeader
{ alg :: Text, -- key algorithm, ES256 for APNS
kid :: Text -- key ID
}
deriving (Show)
data JWTClaims = JWTClaims
{ iss :: Text, -- issuer, team ID for APNS
iat :: Int64 -- issue time, seconds from epoch
}
deriving (Show)
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
$(JQ.deriveToJSON defaultJSON ''JWTHeader)
$(JQ.deriveToJSON defaultJSON ''JWTClaims)
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
-- 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 EC.PrivateKey {private_curve = ECT.getCurveByName privkeyEC_name, private_d = privkeyEC_priv}
data PushNotification
= PNVerification NtfRegCode
| PNMessage (NonEmpty PNMessageData)
| -- | PNAlert Text
PNCheckMessages
deriving (Show)
data APNSNotification = APNSNotification {aps :: APNSNotificationBody, notificationData :: Maybe J.Value}
deriving (Show)
@@ -182,7 +124,7 @@ data APNSPushClientConfig = APNSPushClientConfig
caStoreFile :: FilePath
}
apnsProviderHost :: PushProvider -> Maybe HostName
apnsProviderHost :: APNSProvider -> Maybe HostName
apnsProviderHost = \case
PPApnsNull -> Nothing
PPApnsTest -> Just "localhost"
@@ -218,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}
@@ -236,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
@@ -308,24 +251,15 @@ apnsRequest c tkn ntf@APNSNotification {aps} = do
APNSBackground {} -> "background"
_ -> "alert"
data PushProviderError
= PPConnection HTTP2ClientError
| PPCryptoError C.CryptoError
| PPResponseError (Maybe Status) Text
| PPTokenInvalid NTInvalidReason
| PPRetryLater
| PPPermanentError
deriving (Show, Exception)
type PushProviderClient = NtfTknRec -> PushNotification -> ExceptT PushProviderError IO ()
-- this is not a newtype on purpose to have a correct JSON encoding as a record
data APNSErrorResponse = APNSErrorResponse {reason :: Text}
$(JQ.deriveFromJSON defaultJSON ''APNSErrorResponse)
-- TODO [webpush] change type accept token components so it only allows APNS token
apnsPushProviderClient :: APNSPushClient -> PushProviderClient
apnsPushProviderClient c@APNSPushClient {nonceDrg, apnsCfg} tkn@NtfTknRec {token = DeviceToken _ tknStr} pn = do
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
@@ -0,0 +1,225 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# 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
import Control.Exception (SomeException, fromException, try)
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 (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 (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 (..), NtfRegCode (..), WPAuth (..), WPKey (..), WPP256dh (..), WPTokenParams (..), encodePNMessages, wpAud, wpRequest)
import Simplex.Messaging.Notifications.Server.Push
import Simplex.Messaging.Notifications.Server.Store.Types
import Simplex.Messaging.Util (liftError', safeDecodeUtf8, tshow)
import UnliftIO.STM
-- | 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 token
vapidH <- liftError' toPPWPError $ try $ getVapidHeader (vapidKey wpConfig) cache $ wpAud pp
logDebug $ "Web Push request to " <> tshow (host r)
encBody <- withExceptT PPCryptoError $ wpEncrypt c tkn params pn
let requestHeaders =
[ ("TTL", "2592000"), -- 30 days
("Urgency", "high"),
("Content-Encoding", "aes128gcm"),
("Authorization", vapidH)
-- TODO: topic for pings and interval
]
req =
r
{ method = "POST",
requestHeaders,
requestBody = RequestBodyBS encBody,
redirectCount = 0
}
void $ liftError' toPPWPError $ try $ httpNoBody req manager
-- | encrypt :: UA key -> clear -> cipher
-- | https://www.rfc-editor.org/rfc/rfc8291#section-3.4
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
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 = 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
ikm = hmac prkKey (keyInfo <> "\x01")
prk = hmac salt ikm
cekInfo = "Content-Encoding: aes128gcm\0" :: ByteString
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 = 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 <- liftEither $ C.gcmIV nonce
-- The last record uses a padding delimiter octet set to the value 0x02
(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 $ 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
toPPWPError :: SomeException -> PushProviderError
toPPWPError e = case fromException e of
Just (InvalidUrlException _ _) -> PPWPInvalidUrl
Just (HttpExceptionRequest _ (StatusCodeException resp _)) -> fromStatusCode (responseStatus resp) ("" :: String)
_ -> PPWPOtherError e
where
fromStatusCode status reason
| status == N.status200 = PPWPRemovedEndpoint
| status == N.status410 = PPWPRemovedEndpoint
| status == N.status413 = PPWPRequestTooLong
| status == N.status429 = PPRetryLater
| status >= N.status500 = PPRetryLater
| otherwise = PPResponseError (Just status) (tshow reason)
@@ -12,7 +12,8 @@ import Text.RawString.QQ (r)
ntfServerSchemaMigrations :: [(String, Text, Maybe Text)]
ntfServerSchemaMigrations =
[ ("20250417_initial", m20250417_initial, Nothing),
("20250517_service_cert", m20250517_service_cert, Just down_m20250517_service_cert)
("20250517_service_cert", m20250517_service_cert, Just down_m20250517_service_cert),
("20250916_webpush", m20250916_webpush, Just down_m20250916_webpush)
]
-- | The list of migrations in ascending order by date
@@ -78,7 +79,7 @@ CREATE INDEX idx_last_notifications_token_id_sent_at ON last_notifications(token
CREATE INDEX idx_last_notifications_subscription_id ON last_notifications(subscription_id);
CREATE UNIQUE INDEX idx_last_notifications_token_subscription ON last_notifications(token_id, subscription_id);
|]
|]
m20250517_service_cert :: Text
m20250517_service_cert =
@@ -89,7 +90,7 @@ ALTER TABLE subscriptions ADD COLUMN ntf_service_assoc BOOLEAN NOT NULL DEFAULT
DROP INDEX idx_subscriptions_smp_server_id_status;
CREATE INDEX idx_subscriptions_smp_server_id_ntf_service_status ON subscriptions(smp_server_id, ntf_service_assoc, status);
|]
|]
down_m20250517_service_cert :: Text
down_m20250517_service_cert =
@@ -100,4 +101,33 @@ CREATE INDEX idx_subscriptions_smp_server_id_status ON subscriptions(smp_server_
ALTER TABLE smp_servers DROP COLUMN ntf_service_id;
ALTER TABLE subscriptions DROP COLUMN ntf_service_assoc;
|]
|]
m20250916_webpush :: Text
m20250916_webpush =
[r|
CREATE TABLE webpush_servers(
wp_server_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
wp_host TEXT NOT NULL,
wp_port TEXT NOT NULL,
wp_keyhash BYTEA NOT NULL
);
ALTER TABLE tokens
ADD COLUMN wp_server_id BIGINT REFERENCES webpush_servers ON DELETE RESTRICT ON UPDATE RESTRICT,
ADD COLUMN wp_path TEXT,
ADD COLUMN wp_auth BYTEA,
ADD COLUMN wp_key BYTEA;
|]
down_m20250916_webpush :: Text
down_m20250916_webpush =
[r|
ALTER TABLE tokens
DROP COLUMN wp_server_id,
DROP COLUMN wp_path,
DROP COLUMN wp_auth,
DROP COLUMN wp_key;
DROP TABLE webpush_servers;
|]
@@ -128,8 +128,9 @@ insertNtfTknQuery =
|]
replaceNtfToken :: NtfPostgresStore -> NtfTknRec -> IO (Either ErrorType ())
replaceNtfToken st NtfTknRec {ntfTknId, token = token@(DeviceToken pp ppToken), tknStatus, tknRegCode = code@(NtfRegCode regCode)} =
replaceNtfToken st NtfTknRec {ntfTknId, token, tknStatus, tknRegCode = code@(NtfRegCode regCode)} =
withFastDB "replaceNtfToken" st $ \db -> runExceptT $ do
let (pp, ppToken) = deviceTokenFields token
ExceptT $ assertUpdated <$>
DB.execute
db
@@ -143,7 +144,7 @@ replaceNtfToken st NtfTknRec {ntfTknId, token = token@(DeviceToken pp ppToken),
ntfTknToRow :: NtfTknRec -> NtfTknRow
ntfTknToRow NtfTknRec {ntfTknId, token, tknStatus, tknVerifyKey, tknDhPrivKey, tknDhSecret, tknRegCode, tknCronInterval, tknUpdatedAt} =
let DeviceToken pp ppToken = token
let (pp, ppToken) = deviceTokenFields token
NtfRegCode regCode = tknRegCode
in (ntfTknId, pp, Binary ppToken, tknStatus, tknVerifyKey, tknDhPrivKey, tknDhSecret, Binary regCode, tknCronInterval, tknUpdatedAt)
@@ -153,7 +154,8 @@ getNtfToken st tknId =
getNtfToken_ st " WHERE token_id = ?" (Only tknId)
findNtfTokenRegistration :: NtfPostgresStore -> NewNtfEntity 'Token -> IO (Either ErrorType (Maybe NtfTknRec))
findNtfTokenRegistration st (NewNtfTkn (DeviceToken pp ppToken) tknVerifyKey _) =
findNtfTokenRegistration st (NewNtfTkn token tknVerifyKey _) = do
let (pp, ppToken) = deviceTokenFields token
getNtfToken_ st " WHERE push_provider = ? AND push_provider_token = ? AND verify_key = ?" (pp, Binary ppToken, tknVerifyKey)
getNtfToken_ :: ToRow q => NtfPostgresStore -> Query -> q -> IO (Either ErrorType (Maybe NtfTknRec))
@@ -181,7 +183,7 @@ ntfTknQuery =
rowToNtfTkn :: NtfTknRow -> NtfTknRec
rowToNtfTkn (ntfTknId, pp, Binary ppToken, tknStatus, tknVerifyKey, tknDhPrivKey, tknDhSecret, Binary regCode, tknCronInterval, tknUpdatedAt) =
let token = DeviceToken pp ppToken
let token = deviceToken' pp ppToken
tknRegCode = NtfRegCode regCode
in NtfTknRec {ntfTknId, token, tknStatus, tknVerifyKey, tknDhPrivKey, tknDhSecret, tknRegCode, tknCronInterval, tknUpdatedAt}
@@ -376,8 +378,9 @@ setTknStatusConfirmed st NtfTknRec {ntfTknId} =
when (updated > 0) $ withLog "updateTknStatus" st $ \sl -> logTokenStatus sl ntfTknId NTConfirmed
setTokenActive :: NtfPostgresStore -> NtfTknRec -> IO (Either ErrorType ())
setTokenActive st tkn@NtfTknRec {ntfTknId, token = DeviceToken pp ppToken} =
setTokenActive st tkn@NtfTknRec {ntfTknId, token} =
withFastDB' "setTokenActive" st $ \db -> do
let (pp, ppToken) = deviceTokenFields token
updateTknStatus_ st db tkn NTActive
-- this removes other instances of the same token, e.g. because of repeated token registration attempts
tknIds <-
@@ -92,7 +92,31 @@ CREATE TABLE ntf_server.tokens (
reg_code bytea NOT NULL,
cron_interval bigint NOT NULL,
cron_sent_at bigint,
updated_at bigint
updated_at bigint,
wp_server_id bigint,
wp_path text,
wp_auth bytea,
wp_key bytea
);
CREATE TABLE ntf_server.webpush_servers (
wp_server_id bigint NOT NULL,
wp_host text NOT NULL,
wp_port text NOT NULL,
wp_keyhash bytea NOT NULL
);
ALTER TABLE ntf_server.webpush_servers ALTER COLUMN wp_server_id ADD GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME ntf_server.webpush_servers_wp_server_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
@@ -122,6 +146,11 @@ ALTER TABLE ONLY ntf_server.tokens
ALTER TABLE ONLY ntf_server.webpush_servers
ADD CONSTRAINT webpush_servers_pkey PRIMARY KEY (wp_server_id);
CREATE INDEX idx_last_notifications_subscription_id ON ntf_server.last_notifications USING btree (subscription_id);
@@ -178,3 +207,8 @@ ALTER TABLE ONLY ntf_server.subscriptions
ALTER TABLE ONLY ntf_server.tokens
ADD CONSTRAINT tokens_wp_server_id_fkey FOREIGN KEY (wp_server_id) REFERENCES ntf_server.webpush_servers(wp_server_id) ON UPDATE RESTRICT ON DELETE RESTRICT;
+7
View File
@@ -9,6 +9,7 @@ import qualified Data.ByteString.Char8 as B
import Data.Functor (($>))
import Network.Socket (HostName, ServiceName)
import Simplex.Messaging.Encoding.String (StrEncoding (..))
import Simplex.Messaging.Encoding (Encoding(..))
data ServiceScheme = SSSimplex | SSAppServer SrvLoc
deriving (Eq, Show)
@@ -24,6 +25,12 @@ instance StrEncoding ServiceScheme where
data SrvLoc = SrvLoc HostName ServiceName
deriving (Eq, Ord, Show)
instance Encoding SrvLoc where
smpEncode (SrvLoc h s) = smpEncode (h, s)
smpP = do
(h, s) <- smpP
pure $ SrvLoc h s
instance StrEncoding SrvLoc where
strEncode (SrvLoc host port) = B.pack $ host <> if null port then "" else ':' : port
strP = SrvLoc <$> host <*> (port <|> pure "")
+78 -70
View File
@@ -205,8 +205,9 @@ checkNtfToken c = A.checkNtfToken c NRMInteractive
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 $ \_ ->
@@ -216,11 +217,11 @@ runNtfTestCfg (t, msType) baseId smpCfg ntfCfg aCfg bCfg runTest = do
threadDelay 100000
testNotificationToken :: APNSMockServer -> IO ()
testNotificationToken apns = do
testNotificationToken (APNSMockServer apns) = do
withAgent 1 agentCfg initAgentServers testDB $ \a -> runRight_ $ do
let tkn = DeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
verification <- ntfData .-> "verification"
nonce <- C.cbNonce <$> ntfData .-> "nonce"
@@ -240,16 +241,16 @@ v .-> key = do
-- logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
testNtfTokenRepeatRegistration :: APNSMockServer -> IO ()
testNtfTokenRepeatRegistration apns = do
testNtfTokenRepeatRegistration (APNSMockServer apns) = do
withAgent 1 agentCfg initAgentServers testDB $ \a -> runRight_ $ do
let tkn = DeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
verification <- ntfData .-> "verification"
nonce <- C.cbNonce <$> ntfData .-> "nonce"
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData'}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData'}} <-
getMockNotification apns tkn
_ <- ntfData' .-> "verification"
_ <- C.cbNonce <$> ntfData' .-> "nonce"
@@ -259,18 +260,18 @@ testNtfTokenRepeatRegistration apns = do
pure ()
testNtfTokenSecondRegistration :: APNSMockServer -> IO ()
testNtfTokenSecondRegistration apns =
testNtfTokenSecondRegistration (APNSMockServer apns) =
withAgentClients2 $ \a a' -> runRight_ $ do
let tkn = DeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
verification <- ntfData .-> "verification"
nonce <- C.cbNonce <$> ntfData .-> "nonce"
verifyNtfToken a tkn nonce verification
NTRegistered <- registerNtfToken a' tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData'}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData'}} <-
getMockNotification apns tkn
verification' <- ntfData' .-> "verification"
nonce' <- C.cbNonce <$> ntfData' .-> "nonce"
@@ -289,12 +290,12 @@ testNtfTokenSecondRegistration apns =
pure ()
testNtfTokenServerRestart :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestart t apns = do
let tkn = DeviceToken PPApnsTest "abcd"
testNtfTokenServerRestart t (APNSMockServer apns) = do
let tkn = APNSDeviceToken PPApnsTest "abcd"
ntfData <- withAgent 1 agentCfg initAgentServers testDB $ \a ->
withNtfServer t $ runRight $ do
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
pure ntfData
-- the new agent is created as otherwise when running the tests in CI the old agent was keeping the connection to the server
@@ -310,12 +311,12 @@ testNtfTokenServerRestart t apns = do
pure ()
testNtfTokenServerRestartReverify :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestartReverify t apns = do
let tkn = DeviceToken PPApnsTest "abcd"
testNtfTokenServerRestartReverify t (APNSMockServer apns) = do
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAgent 1 agentCfg initAgentServers testDB $ \a -> do
ntfData <- withNtfServer t $ runRight $ do
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
pure ntfData
runRight_ $ do
@@ -333,12 +334,12 @@ testNtfTokenServerRestartReverify t apns = do
pure ()
testNtfTokenServerRestartReverifyTimeout :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestartReverifyTimeout t apns = do
let tkn = DeviceToken PPApnsTest "abcd"
testNtfTokenServerRestartReverifyTimeout t (APNSMockServer apns) = do
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
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
verification <- ntfData .-> "verification"
nonce <- C.cbNonce <$> ntfData .-> "nonce"
@@ -355,7 +356,7 @@ testNtfTokenServerRestartReverifyTimeout t apns = do
SET tkn_status = ?, tkn_action = ?
WHERE provider = ? AND device_token = ?
|]
(NTConfirmed, Just (NTAVerify code), PPApnsTest, "abcd" :: ByteString)
(NTConfirmed, Just (NTAVerify code), PPAPNS PPApnsTest, "abcd" :: ByteString)
Just NtfToken {ntfTknStatus = NTConfirmed, ntfTknAction = Just (NTAVerify _)} <- withTransaction store getSavedNtfToken
pure ()
threadDelay 1500000
@@ -368,12 +369,12 @@ testNtfTokenServerRestartReverifyTimeout t apns = do
pure ()
testNtfTokenServerRestartReregister :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestartReregister t apns = do
let tkn = DeviceToken PPApnsTest "abcd"
testNtfTokenServerRestartReregister t (APNSMockServer apns) = do
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAgent 1 agentCfg initAgentServers testDB $ \a ->
withNtfServer t $ runRight $ do
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just _}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just _}} <-
getMockNotification apns tkn
pure ()
-- the new agent is created as otherwise when running the tests in CI the old agent was keeping the connection to the server
@@ -383,7 +384,7 @@ testNtfTokenServerRestartReregister t apns = do
-- so that repeat registration happens when client is restarted.
withNtfServer t $ runRight_ $ do
NTRegistered <- registerNtfToken a' tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
verification <- ntfData .-> "verification"
nonce <- C.cbNonce <$> ntfData .-> "nonce"
@@ -392,12 +393,12 @@ testNtfTokenServerRestartReregister t apns = do
pure ()
testNtfTokenServerRestartReregisterTimeout :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenServerRestartReregisterTimeout t apns = do
let tkn = DeviceToken PPApnsTest "abcd"
testNtfTokenServerRestartReregisterTimeout t (APNSMockServer apns) = do
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
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just _}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just _}} <-
getMockNotification apns tkn
pure ()
-- this emulates the situation when server registered token but the client did not receive the response
@@ -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, 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
@@ -418,7 +419,7 @@ testNtfTokenServerRestartReregisterTimeout t apns = do
-- so that repeat registration happens when client is restarted.
withNtfServer t $ runRight_ $ do
NTRegistered <- registerNtfToken a' tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
verification <- ntfData .-> "verification"
nonce <- C.cbNonce <$> ntfData .-> "nonce"
@@ -433,14 +434,14 @@ getTestNtfTokenPort a =
Nothing -> error "no active NtfToken"
testNtfTokenMultipleServers :: ASrvTransport -> APNSMockServer -> IO ()
testNtfTokenMultipleServers t apns = do
let tkn = DeviceToken PPApnsTest "abcd"
testNtfTokenMultipleServers t (APNSMockServer apns) = do
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAgent 1 agentCfg initAgentServers2 testDB $ \a ->
withNtfServerThreadOn t ntfTestPort ntfTestDBCfg $ \ntf ->
withNtfServerThreadOn t ntfTestPort2 ntfTestDBCfg2 $ \ntf2 -> runRight_ $ do
-- register a new token, the agent picks a server and stores its choice
NTRegistered <- registerNtfToken a tkn NMPeriodic
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
verification <- ntfData .-> "verification"
nonce <- C.cbNonce <$> ntfData .-> "nonce"
@@ -543,7 +544,7 @@ testRunNTFServerTests t srv =
testProtocolServer a NRMInteractive 1 $ ProtoServerWithAuth srv Nothing
testNotificationSubscriptionExistingConnection :: APNSMockServer -> AgentMsgId -> AgentClient -> AgentClient -> IO ()
testNotificationSubscriptionExistingConnection apns baseId alice@AgentClient {agentEnv = Env {config = aliceCfg, store}} bob = do
testNotificationSubscriptionExistingConnection (APNSMockServer apns) baseId alice@AgentClient {agentEnv = Env {config = aliceCfg, store}} bob = do
(bobId, aliceId, nonce, message) <- runRight $ do
-- establish connection
(bobId, qInfo) <- createConnection alice 1 True SCMInvitation Nothing SMSubscribe
@@ -554,9 +555,9 @@ testNotificationSubscriptionExistingConnection apns baseId alice@AgentClient {ag
get alice ##> ("", bobId, CON)
get bob ##> ("", aliceId, CON)
-- register notification token
let tkn = DeviceToken PPApnsTest "abcd"
let tkn = APNSDeviceToken PPApnsTest "abcd"
NTRegistered <- registerNtfToken alice tkn NMInstant
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
verification <- ntfData .-> "verification"
vNonce <- C.cbNonce <$> ntfData .-> "nonce"
@@ -566,7 +567,7 @@ testNotificationSubscriptionExistingConnection apns baseId alice@AgentClient {ag
1 <- msgId <$> sendMessage bob aliceId (SMP.MsgFlags True) "hello"
get bob ##> ("", aliceId, SENT $ baseId + 1)
-- notification
(nonce, message) <- messageNotification apns tkn
(nonce, message) <- messageNotification (APNSMockServer apns) tkn
pure (bobId, aliceId, nonce, message)
Right [NotificationInfo {ntfConnId = cId, ntfMsgMeta = Just NMsgMeta {msgTs}}] <- runExceptT $ getNotificationConns alice nonce message
@@ -599,7 +600,7 @@ testNotificationSubscriptionExistingConnection apns baseId alice@AgentClient {ag
2 <- msgId <$> sendMessage bob aliceId (SMP.MsgFlags True) "hello again"
get bob ##> ("", aliceId, SENT $ baseId + 2)
-- no notifications should follow
noNotification alice apns
noNotification alice $ APNSMockServer apns
where
msgId = subtract baseId
@@ -607,9 +608,9 @@ testNotificationSubscriptionNewConnection :: HasCallStack => APNSMockServer -> A
testNotificationSubscriptionNewConnection apns baseId alice bob =
runRight_ $ do
-- alice registers notification token
DeviceToken {} <- registerTestToken alice "abcd" NMInstant apns
APNSDeviceToken {} <- registerTestToken alice "abcd" NMInstant apns
-- bob registers notification token
DeviceToken {} <- 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
@@ -644,10 +645,10 @@ testNotificationSubscriptionNewConnection apns baseId alice bob =
msgId = subtract baseId
registerTestToken :: AgentClient -> ByteString -> NotificationsMode -> APNSMockServer -> ExceptT AgentErrorType IO DeviceToken
registerTestToken a token mode apns = do
let tkn = DeviceToken PPApnsTest token
registerTestToken a token mode (APNSMockServer apns) = do
let tkn = APNSDeviceToken PPApnsTest token
NTRegistered <- registerNtfToken a tkn mode
Just APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData'}} <-
Just PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData'}} <-
timeout 1000000 $ getMockNotification apns tkn
verification' <- ntfData' .-> "verification"
nonce' <- C.cbNonce <$> ntfData' .-> "nonce"
@@ -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
@@ -1024,10 +1032,10 @@ testMessage_ apns a aId b bId msg = do
ackMessage a bId msgId Nothing
messageNotification :: HasCallStack => APNSMockServer -> DeviceToken -> ExceptT AgentErrorType IO (C.CbNonce, ByteString)
messageNotification apns tkn = do
messageNotification (APNSMockServer apns) tkn = do
500000 `timeout` getMockNotification apns tkn >>= \case
Nothing -> error "no notification"
Just APNSMockRequest {notification = APNSNotification {aps = APNSMutableContent {}, notificationData = Just ntfData}} -> do
Just PushMockRequest {notification = APNSNotification {aps = APNSMutableContent {}, notificationData = Just ntfData}} -> do
nonce <- C.cbNonce <$> ntfData .-> "nonce"
message <- ntfData .-> "message"
pure (nonce, message)
@@ -1041,14 +1049,14 @@ messageNotificationData c apns = do
pure $ L.last pnMsgs
noNotification :: AgentClient -> APNSMockServer -> ExceptT AgentErrorType IO ()
noNotification c apns = do
noNotification c (APNSMockServer apns) = do
NtfToken {deviceToken} <- getNtfTokenData c
500000 `timeout` getMockNotification apns deviceToken >>= \case
Nothing -> pure ()
_ -> error "unexpected notification"
noNotifications :: APNSMockServer -> ExceptT AgentErrorType IO ()
noNotifications apns = do
noNotifications (APNSMockServer apns) = do
500000 `timeout` getAnyMockNotification apns >>= \case
Nothing -> pure ()
_ -> error "unexpected notification"
+126 -61
View File
@@ -16,20 +16,25 @@
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
import Data.Aeson (FromJSON (..), ToJSON (..), (.:))
import qualified Data.Aeson as J
import Data.Aeson.Types ((.=))
import qualified Data.Aeson.Types as JT
import Data.ByteString.Builder (lazyByteString)
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.List.NonEmpty (NonEmpty)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo)
import GHC.Generics (Generic)
import qualified Network.HPACK as H
import qualified Network.HPACK.Token as H
import Network.HTTP.Types (Status)
import qualified Network.HTTP.Types as N
import qualified Network.HTTP2.Server as H
@@ -41,11 +46,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 (DeviceToken (..), NtfResponse)
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfResponse, WPTokenParams (..))
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 (..))
@@ -67,6 +74,9 @@ testHost = "localhost"
apnsTestPort :: ServiceName
apnsTestPort = "6010"
wpTestPort :: ServiceName
wpTestPort = "6011"
testKeyHash :: C.KeyHash
testKeyHash = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI="
@@ -124,55 +134,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} =
@@ -212,23 +231,34 @@ ntfServerTest _ t = runNtfTest $ \h -> tPut' h t >> tGet' h
ntfTest :: Transport c => TProxy c 'TServer -> (THandleNTF c 'TClient -> IO ()) -> Expectation
ntfTest _ test' = runNtfTest test' `shouldReturn` ()
data APNSMockRequest = APNSMockRequest
{ notification :: APNSNotification
data PushMockRequest a = PushMockRequest
{ notification :: a
}
data APNSMockResponse = APNSRespOk | APNSRespError Status Text
data PushMockResponse = PushRespOk | PushRespError Status Text
data APNSMockServer = APNSMockServer
data PushMockServer a = PushMockServer
{ action :: Async (),
notifications :: TM.TMap ByteString (TBQueue APNSMockRequest),
notifications :: TM.TMap ByteString (TBQueue (PushMockRequest a)),
http2Server :: HTTP2Server
}
apnsMockServerConfig :: HTTP2ServerConfig
apnsMockServerConfig =
data WPNotification = WPNotification
{ authorization :: Maybe ByteString,
encoding :: Maybe ByteString,
ttl :: Maybe ByteString,
urgency :: Maybe ByteString,
body :: ByteString
}
newtype APNSMockServer = APNSMockServer (PushMockServer APNSNotification)
newtype WPMockServer = WPMockServer (PushMockServer WPNotification)
pushMockServerConfig :: ServiceName -> HTTP2ServerConfig
pushMockServerConfig port =
HTTP2ServerConfig
{ qSize = 2,
http2Port = apnsTestPort,
http2Port = port,
bufferSize = 16384,
bodyHeadSize = 16384,
serverSupported = http2TLSParams,
@@ -242,7 +272,14 @@ apnsMockServerConfig =
}
withAPNSMockServer :: (APNSMockServer -> IO a) -> IO a
withAPNSMockServer = E.bracket (getAPNSMockServer apnsMockServerConfig) closeAPNSMockServer
withAPNSMockServer = E.bracket (getAPNSMockServer $ pushMockServerConfig apnsTestPort) closeAPNSMockServer
where
closeAPNSMockServer (APNSMockServer a) = closePushMockServer a
withWPMockServer :: (WPMockServer -> IO a) -> IO a
withWPMockServer = E.bracket (getWPMockServer $ pushMockServerConfig wpTestPort) closeWPMockServer
where
closeWPMockServer (WPMockServer a) = closePushMockServer a
deriving instance Generic APNSAlertBody
@@ -272,35 +309,63 @@ getAPNSMockServer config@HTTP2ServerConfig {qSize} = do
http2Server <- getHTTP2Server config
notifications <- TM.emptyIO
action <- async $ runAPNSMockServer notifications http2Server
pure APNSMockServer {action, notifications, http2Server}
pure $ APNSMockServer PushMockServer {action, notifications, http2Server}
where
runAPNSMockServer notifications HTTP2Server {reqQ} = forever $ do
HTTP2Request {request, reqBody = HTTP2Body {bodyHead}, sendResponse} <- atomically $ readTBQueue reqQ
let sendApnsResponse = \case
APNSRespOk -> sendResponse $ H.responseNoBody N.ok200 []
APNSRespError status reason ->
PushRespOk -> sendResponse $ H.responseNoBody N.ok200 []
PushRespError status reason ->
sendResponse . H.responseBuilder status [] . lazyByteString $ J.encode APNSErrorResponse {reason}
case J.decodeStrict' bodyHead of
Just notification -> do
Just token <- pure $ B.stripPrefix "/3/device/" =<< H.requestPath request
q <- atomically $ TM.lookup token notifications >>= maybe (newTokenQueue token) pure
atomically $ writeTBQueue q APNSMockRequest {notification}
sendApnsResponse APNSRespOk
atomically $ writeTBQueue q PushMockRequest {notification}
sendApnsResponse PushRespOk
where
newTokenQueue token = newTBQueue qSize >>= \q -> TM.insert token q notifications >> pure q
_ -> do
putStrLn $ "runAPNSMockServer J.decodeStrict' error, reqBody: " <> show bodyHead
sendApnsResponse $ APNSRespError N.badRequest400 "bad_request_body"
sendApnsResponse $ PushRespError N.badRequest400 "bad_request_body"
getMockNotification :: MonadIO m => APNSMockServer -> DeviceToken -> m APNSMockRequest
getMockNotification APNSMockServer {notifications} (DeviceToken _ token) = do
getWPMockServer :: HTTP2ServerConfig -> IO WPMockServer
getWPMockServer config@HTTP2ServerConfig {qSize} = do
http2Server <- getHTTP2Server config
notifications <- TM.emptyIO
action <- async $ runWPMockServer notifications http2Server
pure $ WPMockServer PushMockServer {action, notifications, http2Server}
where
runWPMockServer notifications HTTP2Server {reqQ} = forever $ do
HTTP2Request {request, reqBody = HTTP2Body {bodyHead}, sendResponse} <- atomically $ readTBQueue reqQ
let sendWPResponse = \case
PushRespOk -> sendResponse $ H.responseNoBody N.ok200 []
PushRespError status reason ->
sendResponse . H.responseBuilder status [] . lazyByteString $ J.encode $ J.object ["error" .= reason]
path = fromMaybe "/default" $ H.requestPath request
(_, headers) = H.requestHeaders request
authorization = H.getHeaderValue H.tokenAuthorization headers
encoding = H.getHeaderValue H.tokenContentEncoding headers
ttl = H.getHeaderValue (H.toToken "TTL") headers
urgency = H.getHeaderValue (H.toToken "urgency") headers
notification = WPNotification {body = bodyHead, authorization, encoding, ttl, urgency}
q <- atomically $ TM.lookup path notifications >>= maybe (newTokenQueue path) pure
atomically $ writeTBQueue q PushMockRequest {notification}
sendWPResponse PushRespOk
where
newTokenQueue path = newTBQueue qSize >>= \q -> TM.insert path q notifications >> pure q
getMockNotification :: MonadIO m => PushMockServer a -> DeviceToken -> m (PushMockRequest a)
getMockNotification PushMockServer {notifications} (WPDeviceToken _ (WPTokenParams path _)) = do
atomically $ TM.lookup path notifications >>= maybe retry readTBQueue
getMockNotification PushMockServer {notifications} (APNSDeviceToken _ token) = do
atomically $ TM.lookup token notifications >>= maybe retry readTBQueue
getAnyMockNotification :: MonadIO m => APNSMockServer -> m APNSMockRequest
getAnyMockNotification APNSMockServer {notifications} = do
getAnyMockNotification :: MonadIO m => PushMockServer a -> m (PushMockRequest a)
getAnyMockNotification PushMockServer {notifications} = do
atomically $ readTVar notifications >>= mapM readTBQueue . M.elems >>= \case [] -> retry; ntf : _ -> pure ntf
closeAPNSMockServer :: APNSMockServer -> IO ()
closeAPNSMockServer APNSMockServer {action, http2Server} = do
closePushMockServer :: PushMockServer a -> IO ()
closePushMockServer PushMockServer {action, http2Server} = do
closeHTTP2Server http2Server
uninterruptibleCancel action
+44 -15
View File
@@ -48,11 +48,14 @@ import Simplex.Messaging.Transport
import Test.Hspec hiding (fit, it)
import UnliftIO.STM
import Util
import Simplex.Messaging.Encoding.String (StrEncoding(..))
import System.Environment (setEnv)
ntfServerTests :: (ASrvTransport, AStoreType) -> Spec
ntfServerTests ps@(t, _) = do
describe "Notifications server protocol syntax" $ ntfSyntaxTests t
describe "Notification subscriptions (NKEY)" $ testNotificationSubscription ps createNtfQueueNKEY
describe "APNS notification subscriptions (NKEY)" $ testAPNSNotificationSubscription ps createNtfQueueNKEY
describe "WP notification subscriptions (NKEY)" $ testWPNotificationSubscription ps createNtfQueueNKEY
-- describe "Notification subscriptions (NEW with ntf creds)" $ testNotificationSubscription ps createNtfQueueNEW
describe "Retried notification subscription" $ testRetriedNtfSubscription ps
@@ -99,22 +102,22 @@ v .-> key =
let J.Object o = v
in U.decodeLenient . encodeUtf8 <$> JT.parseEither (J..: key) o
testNotificationSubscription :: (ASrvTransport, AStoreType) -> CreateQueueFunc -> Spec
testNotificationSubscription (ATransport t, msType) createQueue =
it "should create notification subscription and notify when message is received" $ do
testAPNSNotificationSubscription :: (ASrvTransport, AStoreType) -> CreateQueueFunc -> Spec
testAPNSNotificationSubscription (ATransport t, msType) createQueue =
it "should create APNS notification subscription and notify when message is received" $ do
g <- C.newRandom
(sPub, sKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(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 = DeviceToken PPApnsTest "abcd"
withAPNSMockServer $ \apns ->
let tkn = APNSDeviceToken PPApnsTest "abcd"
withAPNSMockServer $ \(APNSMockServer apns) ->
smpTest2 t msType $ \rh sh ->
ntfTest t $ \nh -> do
((sId, rId, rKey, rcvDhSecret), nId, rcvNtfDhSecret) <- createQueue rh sPub nPub
-- register and verify 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}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
let dhSecret = C.dh' ntfDh dhPriv
decryptCode nd =
@@ -127,7 +130,7 @@ testNotificationSubscription (ATransport t, msType) createQueue =
RespNtf "1a" NoEntity (NRTknId tId1 ntfDh1) <- signSendRecvNtf nh tknKey ("1a", NoEntity, TNEW $ NewNtfTkn tkn tknPub dhPub)
tId1 `shouldBe` tId
ntfDh1 `shouldBe` ntfDh
APNSMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData1}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData1}} <-
getMockNotification apns tkn
let code1 = decryptCode ntfData1
code `shouldBe` code1
@@ -141,7 +144,7 @@ testNotificationSubscription (ATransport t, msType) createQueue =
threadDelay 50000
Resp "5" _ OK <- signSendRecv sh sKey ("5", sId, _SEND' "hello")
-- receive notification
APNSMockRequest {notification} <- getMockNotification apns tkn
PushMockRequest {notification} <- getMockNotification apns tkn
let APNSNotification {aps = APNSMutableContent {}, notificationData = Just ntfData'} = notification
Right nonce' = C.cbNonce <$> ntfData' .-> "nonce"
Right message = ntfData' .-> "message"
@@ -160,10 +163,10 @@ testNotificationSubscription (ATransport t, msType) createQueue =
(msgBody, "hello") #== "delivered from queue"
Resp "6" _ OK <- signSendRecv rh rKey ("6", rId, ACK mId1)
-- replace token
let tkn' = DeviceToken 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}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData2}} <-
getMockNotification apns tkn'
let Right verification2 = ntfData2 .-> "verification"
Right nonce2 = C.cbNonce <$> ntfData2 .-> "nonce"
@@ -172,7 +175,7 @@ testNotificationSubscription (ATransport t, msType) createQueue =
RespNtf "8a" _ (NRTkn NTActive) <- signSendRecvNtf nh tknKey ("8a", tId, TCHK)
-- send message
Resp "9" _ OK <- signSendRecv sh sKey ("9", sId, _SEND' "hello 2")
APNSMockRequest {notification = notification3} <- getMockNotification apns tkn'
PushMockRequest {notification = notification3} <- getMockNotification apns tkn'
let APNSNotification {aps = APNSMutableContent {}, notificationData = Just ntfData3} = notification3
Right nonce3 = C.cbNonce <$> ntfData3 .-> "nonce"
Right message3 = ntfData3 .-> "message"
@@ -182,6 +185,32 @@ testNotificationSubscription (ATransport t, msType) createQueue =
smpServer3 `shouldBe` srv
notifierId3 `shouldBe` nId
testWPNotificationSubscription :: (ASrvTransport, AStoreType) -> CreateQueueFunc -> Spec
testWPNotificationSubscription (ATransport t, msType) createQueue =
it "should create WP notification subscription and notify when message is received" $ do
g <- C.newRandom
(sPub, sKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(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 params ::WPTokenParams = either error id $ strDecode "/secret AQ3VfRX3_F38J3ltcmMVRg BKuw4WxupnnrZHqk6vCwoms4tOpitZMvFdR9eAn54yOPY4q9jpXOpl-Ui_FwbIy8ZbFCnuaS7RnO02ahuL4XxIM"
tkn = WPDeviceToken (WPP $ WPSrvLoc $ SrvLoc "localhost" wpTestPort) params
_ <- setEnv "SYSTEM_CERTIFICATE_PATH" "tests/fixtures/"
withWPMockServer $ \(WPMockServer wp) ->
smpTest2 t msType $ \rh sh ->
ntfTest t $ \nh -> do
((sId, rId, rKey, rcvDhSecret), nId, rcvNtfDhSecret) <- createQueue rh sPub nPub
-- register and verify token
RespNtf "1" NoEntity (NRTknId tId ntfDh) <- signSendRecvNtf nh tknKey ("1", NoEntity, TNEW $ NewNtfTkn tkn tknPub dhPub)
PushMockRequest {notification = WPNotification {authorization, encoding, ttl, urgency, body}} <-
getMockNotification wp tkn
encoding `shouldBe` Just "aes128gcm"
ttl `shouldBe` Just "2592000"
urgency `shouldBe` Just "high"
-- TODO: uncomment when vapid is merged
-- authorization `shouldContainBS` "vapid t="
testRetriedNtfSubscription :: (ASrvTransport, AStoreType) -> Spec
testRetriedNtfSubscription (ATransport t, msType) =
it "should allow retrying to create notification subscription with the same token and key" $ do
@@ -233,13 +262,13 @@ createNtfQueueNKEY h sPub nPub = do
pure ((sId, rId, rKey, rcvDhSecret), nId, rcvNtfDhSecret)
registerToken :: Transport c => THandleNTF c 'TClient -> APNSMockServer -> ByteString -> IO (C.APrivateAuthKey, C.DhSecretX25519, NtfEntityId, NtfRegCode)
registerToken nh apns token = do
registerToken nh (APNSMockServer apns) token = do
g <- C.newRandom
(tknPub, tknKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
(dhPub, dhPriv :: C.PrivateKeyX25519) <- atomically $ C.generateKeyPair g
let tkn = DeviceToken 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}} <-
PushMockRequest {notification = APNSNotification {aps = APNSBackground _, notificationData = Just ntfData}} <-
getMockNotification apns tkn
let dhSecret = C.dh' ntfDh dhPriv
decryptCode nd =
+101
View File
@@ -0,0 +1,101 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
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
ntfWPTests :: Spec
ntfWPTests = describe "NTF Protocol" $ do
it "decode WPDeviceToken from string" testWPDeviceTokenStrEncoding
it "decode invalid WPDeviceToken" testInvalidWPDeviceTokenStrEncoding
it "Encrypt RFC8291 example" testWPEncryption
it "Vapid header cache" testVapidCache
testWPDeviceTokenStrEncoding :: Expectation
testWPDeviceTokenStrEncoding = do
let ts = "webpush https://localhost/secret AQ3VfRX3_F38J3ltcmMVRg BKuw4WxupnnrZHqk6vCwoms4tOpitZMvFdR9eAn54yOPY4q9jpXOpl-Ui_FwbIy8ZbFCnuaS7RnO02ahuL4XxIM"
-- let ts = "apns_null test_ntf_token"
-- let ts = "apns_test 11111111222222223333333344444444"
let auth = either error id $ strDecode "AQ3VfRX3_F38J3ltcmMVRg"
let pk = either error id $ strDecode "BKuw4WxupnnrZHqk6vCwoms4tOpitZMvFdR9eAn54yOPY4q9jpXOpl-Ui_FwbIy8ZbFCnuaS7RnO02ahuL4XxIM"
let params :: WPTokenParams = either error id $ strDecode "/secret AQ3VfRX3_F38J3ltcmMVRg BKuw4WxupnnrZHqk6vCwoms4tOpitZMvFdR9eAn54yOPY4q9jpXOpl-Ui_FwbIy8ZbFCnuaS7RnO02ahuL4XxIM"
wpPath params `shouldBe` "/secret"
let key = wpKey params
wpAuth key `shouldBe` auth
wpP256dh key `shouldBe` pk
let pp@(WPP s) :: WPProvider = either error id $ strDecode "webpush https://localhost"
let parsed = either error id $ strDecode ts
parsed `shouldBe` WPDeviceToken pp params
-- TODO: strEncoding should be base64url _without padding_
-- strEncode parsed `shouldBe` ts
strEncode s <> wpPath params `shouldBe` "https://localhost/secret"
testInvalidWPDeviceTokenStrEncoding :: Expectation
testInvalidWPDeviceTokenStrEncoding = do
-- http-client parser parseUrlThrow is very very lax,
-- 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"
t = strDecode ts :: Either String DeviceToken
t `shouldSatisfy` isLeft
-- | Example from RFC8291
testWPEncryption :: Expectation
testWPEncryption = do
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
cipher <- case mCip of
Left _ -> fail "Cannot encrypt clear text"
Right c -> pure c
strEncode cipher `shouldBe` "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
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
+2
View File
@@ -45,6 +45,7 @@ import AgentTests.SchemaDump (schemaDumpTest)
#if defined(dbServerPostgres)
import NtfServerTests (ntfServerTests)
import NtfClient (ntfTestServerDBConnectInfo, ntfTestStoreDBOpts)
import NtfWPTests (ntfWPTests)
import PostgresSchemaDump (postgresSchemaDumpTest)
import SMPClient (testServerDBConnectInfo, testStoreDBOpts)
import Simplex.Messaging.Notifications.Server.Store.Migrations (ntfServerMigrations)
@@ -139,6 +140,7 @@ main = do
-- before (pure $ ASType SQSPostgres SMSJournal) smpProxyTests
describe "SMP proxy, postgres-only message store" $
before (pure $ ASType SQSPostgres SMSPostgres) smpProxyTests
describe "NTF WP tests" ntfWPTests
#endif
-- xdescribe "SMP client agent, server jornal message store" $ agentTests (transport @TLS, ASType SQSMemory SMSJournal)
describe "SMP client agent, server memory message store" $ agentTests (transport @TLS, ASType SQSMemory SMSMemory)
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIMTAncBq2I7G3KvW4C8Y8Heg2cbcDTobbGFQFnBiA5M/oAoGCCqGSM49
AwEHoUQDQgAEiTsBKQSvUDWslEZcwqLvu0AaPd1Gi5KBl1bpLml57treHt+S93Q5
hCLHLjKPflQVm3yF31PABCLJsMr8ckvAkA==
-----END EC PRIVATE KEY-----