Skip to content

Conversation

p1gp1g
Copy link
Collaborator

@p1gp1g p1gp1g commented Aug 27, 2025

This PR is based on #1612

It adds a WebPush push provider and do the web push requests

It doesn't contain the VAPID authorization yet (as the proposed implementation was using extras, that may be dropped)

logDebug "New WP Client requested"
manager <- newManager tlsManagerSettings
let c = wpPushProviderClient manager
atomically $ TM.insert PPWebPush c pushClients
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it'll work, even if the user can only use one push provider, all users can use different push providers, so the server should store in the map all of them, and either the key should be different or push provider should include endpoint (which is probably better, as endpoint is also part of token definition)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so what would happen is that it would be pushing to a random push server

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It includes a single pusher but this pusher will request different URL based on the WPDeviceToken

WPDeviceToken e -> pure e
_ -> fail "Wrong device token"
-- TODO: move to PPIndalidPusher ? WPEndpoint should be invalidated and removed if the key is invalid, but the validation key is never sent
body :: WPEndpoint -> ExceptT PushProviderError IO B.ByteString
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can't use OverloadedRecordDot extension, we need backwards compatibility with 8.10.7. So it should either be unique selectors names (so one can write auth e) or NamedFieldsPun extension so it can be WPEndpoint {auth}

wpPushProviderClient :: Manager -> PushProviderClient
wpPushProviderClient mg tkn pn = do
e <- endpoint tkn
r <- liftPPWPError $ parseUrlThrow $ B.unpack e.endpoint
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this parsing needs to happen when token is received, and converted to type that doesn't require parsing at a point of sending notification

wpEncrypt auth uaPubKS clearT = do
salt :: B.ByteString <- liftIO $ getRandomBytes 16
asPrivK <- liftIO $ ECDH.generatePrivate $ ECC.getCurveByName ECC.SEC_p256r1
uaPubK <- point uaPubKS
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is effectively parsing, should be done in type parser, not here.


wpPushProviderClient :: Manager -> PushProviderClient
wpPushProviderClient mg tkn pn = do
e <- endpoint tkn
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that also should not be failing

, requestBody = RequestBodyBS encBody
, redirectCount = 0
}
_ <- liftPPWPError $ httpNoBody req mg
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't seem to use TLS

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment above

newWPPushClient :: NtfPushServer -> IO PushProviderClient
newWPPushClient NtfPushServer {pushClients} = do
logDebug "New WP Client requested"
manager <- newManager tlsManagerSettings
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to use the same CA store that we use for APNS

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: validate if it uses default system store, most likely it does not.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It uses the default store:

ghci> man <- liftIO $ newManager tlsManagerSettings
ghci> let req = "https://perdu.com" { proxy = Nothing }
ghci> httpLbs req man
Response {responseStatus = Status {statusCode = 200, statusMessage = "OK"}, responseVersion = HTTP/1.1, responseHeaders = [("Date","Thu, 04 Sep 2025 09:10:39 GMT"),("Content-Type","text/html"),("Content-Length","163"),("Connection","keep-alive"),("last-modified","Thu, 02 Jun 2016 06:01:08 GMT"),("etag","\"cc-5344555136fe9-gzip\""),("accept-ranges","bytes"),("Cache-Control","max-age=600"),("expires","Thu, 04 Sep 2025 09:20:39 GMT"),("vary","Accept-Encoding,User-Agent"),("Content-Encoding","gzip"),("Server","cloudflare"),("Nel","{\"report_to\":\"cf-nel\",\"success_fraction\":0.0,\"max_age\":604800}"),("cf-cache-status","DYNAMIC"),("Report-To","{\"group\":\"cf-nel\",\"max_age\":604800,\"endpoints\":[{\"url\":\"https://a.nel.cloudflare.com/report/v4?s=IRxVE2a4P4b%2B9cRuL60q5q%2FHNbSTK7p%2FJcnjIl968IImjzem1ecVR9wcJyVY0bdIgpiFUAzHUvMXa0EJ%2FupmKHozM1qBUmD2jqFhUwdELqttVlE%3D\"}]}"),("CF-RAY","979c5ae08ed73e9e-MRS"),("alt-svc","h3=\":443\"; ma=86400")], responseBody = "<html><head><title>Vous Etes Perdu ?</title></head><body><h1>Perdu sur l'Internet ?</h1><h2>Pas de panique, on va vous aider</h2><strong><pre>    * <----- vous &ecirc;tes ici</pre></strong></body></html>\n", responseCookieJar = CJ {expose = []}, responseClose' = ResponseClose, responseOriginalRequest = Request {
  host                 = "perdu.com"
  port                 = 443
  secure               = True
  requestHeaders       = []
  path                 = "/"
  queryString          = ""
  method               = "GET"
  proxy                = Nothing
  rawBody              = False
  redirectCount        = 10
  responseTimeout      = ResponseTimeoutDefault
  requestVersion       = HTTP/1.1
  proxySecureMode      = ProxySecureWithConnect
}
}
ghci> let req = "https://untrusted-root.badssl.com/" { proxy = Nothing }

<interactive>:31:5: warning: [GHC-63397] [-Wname-shadowing]
    This binding for ‘req’ shadows the existing binding
      defined at <interactive>:19:5
ghci> httpLbs req man
*** Exception: HttpExceptionRequest Request {
  host                 = "untrusted-root.badssl.com"
  port                 = 443
  secure               = True
  requestHeaders       = []
  path                 = "/"
  queryString          = ""
  method               = "GET"
  proxy                = Nothing
  rawBody              = False
  redirectCount        = 10
  responseTimeout      = ResponseTimeoutDefault
  requestVersion       = HTTP/1.1
  proxySecureMode      = ProxySecureWithConnect
}
 (InternalException (HandshakeFailed (Error_Protocol "certificate has unknown CA" UnknownCa)))
ghci>

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tlsManagerSettings loads certificates from /etc/ssl /system/etc/security/cacerts and /usr/local/share/certs ; if no CA are available, all requests fail

@@ -260,6 +260,8 @@ 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Simplex.Messaging.Notifications.Server.Push

@@ -303,6 +306,8 @@ library
, directory ==1.3.*
, filepath ==1.4.*
, hourglass ==0.2.*
, http-client ==0.7.*
, http-client-tls ==0.3.6.*
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extra dependencies should be excluded when client_library flag is set (section below).

@@ -1316,7 +1316,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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These renames are unnecessary, as we still need a unified API for all tokens, both APNS and WebPush. See the type for DeviceToken below.

@@ -1382,7 +1382,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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we most likely need to add fields for webpush tokens to the table.

Comment on lines +899 to +900
| -- | unable to decode ecc key
CryptoInvalidECCKey CE.CryptoError
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| -- | unable to decode ecc key
CryptoInvalidECCKey CE.CryptoError

This error is not necessary as it would be a parsing error.

Comment on lines +456 to +458
data DeviceToken
= APNSDeviceToken PushProvider ByteString
| WPDeviceToken WPEndpoint
Copy link
Member

@epoberezkin epoberezkin Aug 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed type for DeviceToken is

data DeviceToken
  = APNSDeviceToken APNSProvider ByteString
  | WPDeviceToken WPProvider WPTokenParams
  
newtype WPProvider = WPP (ProtocolServer 'WebPush) -- so we can add params if needed
  
data APNSProvider
  = PPApnsDev -- provider for Apple development environment
  | PPApnsProd -- production environment, including TestFlight
  | PPApnsTest
  
data WPTokenParams = WPTokenParams
  { wpPath :: ByteString, -- parser should validate it's a valid type and possibly it should be  
    wpAuth :: ByteString, -- if we enforce size constraints, should also be in parser.
    wpKey :: ECC.Point -- or another correct type that is needed for encryption, so it fails in parser and not there
  }

General approach is to parse early, to the most narrow type that is applicable at the point of parsing, and not at the point of using it.

@@ -377,6 +378,7 @@ data PushProvider
| 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
| PPWebPush -- used for webpush (FCM, UnifiedPush, potentially desktop)
deriving (Eq, Ord, Show)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed type for push provider (it's needed as a key for the connected client), but it probably is not needed during parsing of DeviceToken:

data PushProvider = PPAPNS APNSProvider | PPWP WPProvider

pure $ APNSDeviceToken pp t

-- | Returns fields for the device token (pushProvider, token)
deviceTokenFields :: DeviceToken -> (PushProvider, ByteString)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function seems unnecessary, as it's better to store components as separate fields and split/combine when saving to the database.

@@ -256,7 +257,8 @@ data APNSErrorResponse = APNSErrorResponse {reason :: Text}
$(JQ.deriveFromJSON defaultJSON ''APNSErrorResponse)

apnsPushProviderClient :: APNSPushClient -> PushProviderClient
apnsPushProviderClient c@APNSPushClient {nonceDrg, apnsCfg} tkn@NtfTknRec {token = DeviceToken _ tknStr} pn = do
apnsPushProviderClient c@APNSPushClient {nonceDrg, apnsCfg} tkn@NtfTknRec {token} pn = do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function needs to change to accept token components - APNSProvider and ByteString (token), so it does not fail on incorrect provider type. PPInvalidPusher error most likely is not necessary - it's not a real runtime error.

@epoberezkin
Copy link
Member

Added all (I think:) comments. I probably need to make a change on top with different types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants