Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e90c15b
Move generic push functions to Push.hs
p1gp1g Jun 26, 2025
c08c379
Fix move push
p1gp1g Jul 11, 2025
e7e7c9b
Rename APNSDeviceToken
p1gp1g Aug 27, 2025
64269c2
Add WPDeviceToken
p1gp1g Aug 27, 2025
a24024c
Prepare webpush requests
p1gp1g Jul 16, 2025
2205a1f
Use content of push notif with web push
p1gp1g Jul 16, 2025
1295b28
Lint liftPPWPError
p1gp1g Jul 18, 2025
720fb40
Encrypt wp notifications
p1gp1g Jul 18, 2025
9d30af4
Fix Urgency case
p1gp1g Aug 18, 2025
4a39c4a
Merge branch 'unified-push' into webpush_requests
epoberezkin Aug 29, 2025
c56b04f
Merge branch 'webpush_requests' of github.com:p1gp1g/simplexmq into p…
epoberezkin Sep 16, 2025
f5a8d8b
token types and migration (WIP, does not compile)
epoberezkin Sep 16, 2025
d3d9768
Merge branch 'unified-push' into p1gp1g-webpush_requests
epoberezkin Nov 10, 2025
5e28d4f
[webpush] Parsing during registration (#1661)
p1gp1g Nov 10, 2025
8ee3fd0
fix migration
epoberezkin Nov 10, 2025
7485175
remove comment
epoberezkin Nov 10, 2025
63c4647
refactor
epoberezkin Nov 11, 2025
673f50e
remove unused error constructor
epoberezkin Nov 11, 2025
a493128
remove function
epoberezkin Nov 11, 2025
4257255
fix test
epoberezkin Nov 11, 2025
17fe6ed
instance
epoberezkin Nov 11, 2025
1007deb
refactor
epoberezkin Nov 11, 2025
3402d64
Move functions to encode/decode EC keys to Crypto module
p1gp1g Oct 30, 2025
c50c77d
Add WebPush config with VAPID key to NTF server
p1gp1g Oct 31, 2025
49a7e26
Send VAPID header with webpush requests
p1gp1g Oct 31, 2025
28aa2da
Add safety delay for VAPID header expirity
p1gp1g Oct 31, 2025
323d6da
Fix compilation with GHC 8
p1gp1g Oct 31, 2025
9c6f32c
Merge pull request #1663 from p1gp1g/vapid
epoberezkin Jan 16, 2026
229c895
ntf: e2e encrypt notifications, clean up encryption (#1698)
epoberezkin Jan 19, 2026
ea9adf1
webpush: small fixes (#1699)
epoberezkin Jan 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions simplexmq.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ library
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
Expand Down Expand Up @@ -298,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.*
Expand All @@ -310,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.*
Expand Down Expand Up @@ -341,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.*
Expand Down Expand Up @@ -510,6 +514,7 @@ test-suite simplexmq-test
AgentTests.NotificationTests
NtfClient
NtfServerTests
NtfWPTests
PostgresSchemaDump
hs-source-dirs:
tests
Expand Down
2 changes: 1 addition & 1 deletion src/Simplex/Messaging/Agent/Client.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 18 additions & 9 deletions src/Simplex/Messaging/Agent/Store/AgentStore.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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|
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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|
Expand Down Expand Up @@ -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} =
Expand Down
92 changes: 83 additions & 9 deletions src/Simplex/Messaging/Crypto.hs
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,18 @@ module Simplex.Messaging.Crypto
signatureKeyPair,
publicToX509,
encodeASNObj,
readECPrivateKey,

-- * key encoding/decoding
encodePubKey,
decodePubKey,
encodePrivKey,
decodePrivKey,
pubKeyBytes,
encodeBigInt,
uncompressEncodePoint,
uncompressDecodePoint,
uncompressDecodePrivateNumber,

-- * sign/verify
Signature (..),
Expand Down Expand Up @@ -128,6 +133,7 @@ module Simplex.Messaging.Crypto
encryptAEAD,
decryptAEAD,
encryptAESNoPad,
encryptAES128NoPad,
decryptAESNoPad,
authTagSize,
randomAesKey,
Expand Down Expand Up @@ -210,38 +216,44 @@ 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)
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 (+))
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading
Loading