Skip to content

Commit

Permalink
Use pem encoding for vapid keys
Browse files Browse the repository at this point in the history
  • Loading branch information
cotrone committed Nov 8, 2023
1 parent 11c9d0f commit 33d1478
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 76 deletions.
5 changes: 4 additions & 1 deletion cabal.project
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
packages: . ./web-push-example
packages: . ./web-push-example

package cryptostore
flags: +use_crypton
60 changes: 11 additions & 49 deletions src/Web/WebPush.hs
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,27 @@

module Web.WebPush (
-- * Functions
generateVAPIDKeys
, vapidPublicKeyBytes
, sendPushNotification
sendPushNotification
, sendPushNotifications
-- * Types
, Subscription(..)
, VapidConfig(..)
, VAPIDKeysMinDetails(..)
, PushNotification(..)
, PushNotificationCreated(..)
, PushNotificationError(..)
, PushP256dh
, PushAuth
, module Web.WebPush.Keys
) where

import Web.WebPush.Internal
import Web.WebPush.Keys

import Control.Exception (Exception, try)
import Control.Exception.Base (SomeException (..), fromException)
import Control.Exception
import Control.Exception.Safe (tryAny)
import Control.Monad.Except
import qualified Crypto.PubKey.ECC.DH as ECDH
import qualified Crypto.PubKey.ECC.ECDSA as ECDSA
import qualified Crypto.PubKey.ECC.Generate as ECC
import qualified Crypto.PubKey.ECC.Types as ECC
import Crypto.Random (MonadRandom (getRandomBytes))
import qualified Data.Aeson as A
Expand All @@ -47,7 +44,6 @@ import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Read as TR
import Data.Time.Clock.POSIX (getPOSIXTime)
import Data.Word (Word8)
import Network.HTTP.Client (HttpException (HttpExceptionRequest),
HttpExceptionContent (StatusCodeException),
Manager, RequestBody (..),
Expand All @@ -60,44 +56,12 @@ import Network.HTTP.Types.Status (Status (statusCode))
import Network.URI
import System.Random (randomRIO)

-- | 3 integers minimally representing a unique VAPID public-private key pair.
data VAPIDKeysMinDetails = VAPIDKeysMinDetails { privateNumber :: Integer
, publicCoordX :: Integer
, publicCoordY :: Integer
} deriving (Show)

-- | Configuration for VAPID server identification
data VapidConfig = VapidConfig {
vapidConfigContact :: T.Text -- ^ Contact information for the application server, either a `mailto:` URI or an HTTPS URL
, vapidConfigKey :: VAPIDKeysMinDetails -- ^ Keypair used to sign the VAPID identification
, vapidConfigKeys :: VAPIDKeys -- ^ Keypair used to sign the VAPID identification
}

-- | Generate the 3 integers minimally representing a unique pair of public and private keys.
--
-- Store them securely and use them across multiple push notification requests.
generateVAPIDKeys :: MonadRandom m => m (Either String VAPIDKeysMinDetails)
generateVAPIDKeys = do
-- SEC_p256r1 is the NIST P-256
(pubKey, privKey) <- ECC.generate $ ECC.getCurveByName ECC.SEC_p256r1
case ECDSA.public_q pubKey of
ECC.PointO -> pure $ Left "Invalid public key generated, public_q is the point at infinity"
ECC.Point pubX pubY ->
pure $ Right VAPIDKeysMinDetails {
privateNumber = ECDSA.private_d privKey
, publicCoordX = pubX
, publicCoordY = pubY
}

-- | Pass the VAPID public key bytes as `applicationServerKey` when calling subscribe
-- on the `PushManager` object on a registered service worker
--
-- > applicationServerKey = new Uint8Array( #{toJSON vapidPublicKeyBytes} )
vapidPublicKeyBytes :: VAPIDKeysMinDetails -> [Word8]
vapidPublicKeyBytes keys = BS.unpack $ ecPublicKeyToBytes' (x, y)
where
x = publicCoordX keys
y = publicCoordY keys

-- | Result of a successful push notification request
data PushNotificationCreated = PushNotificationCreated {
pushNotificationCreatedTTL :: Maybe Int -- ^ Optional TTL of the notification
Expand All @@ -119,14 +83,13 @@ sendPushNotifications httpManager vapidConfig pushNotification subscriptions = d
, serverIdentificationExpiration = round time + fromIntegral (pnExpireInSeconds pushNotification)
, serverIdentificationSubject = vapidConfigContact vapidConfig
}
headers <- hostHeaders vapidKeys serverIdentification
headers <- hostHeaders privateKey serverIdentification
forM hostSubscriptions $ \subscription -> do
e <- sendPushNotification' vapidKeys httpManager headers pushNotification subscription
pure (subscription, e)
where
vapidKeysMin = vapidConfigKey vapidConfig
vapidKeys = VAPIDKeys $ ECDSA.KeyPair (ECC.getCurveByName ECC.SEC_p256r1) (vapidKeyPoint) (privateNumber vapidKeysMin)
vapidKeyPoint = ECC.Point (publicCoordX vapidKeysMin) (publicCoordY vapidKeysMin)
privateKey = ECDSA.toPrivateKey $ unVAPIDKeys vapidKeys
vapidKeys = vapidConfigKeys vapidConfig
-- Group subscriptions by host
subscriptionsMap =
Map.fromListWith (<>) $ catMaybes ((\sub -> (,[sub]) <$> uriHost (subscriptionEndpoint sub)) <$> subscriptions)
Expand All @@ -150,12 +113,11 @@ sendPushNotification httpManager vapidConfig pushNotification subscription =
, serverIdentificationExpiration = round time + fromIntegral (pnExpireInSeconds pushNotification)
, serverIdentificationSubject = vapidConfigContact vapidConfig
}
headers <- hostHeaders vapidKeys serverIdentification
headers <- hostHeaders privateKey serverIdentification
sendPushNotification' vapidKeys httpManager headers pushNotification subscription
where
vapidKeysMin = vapidConfigKey vapidConfig
vapidKeys = VAPIDKeys $ ECDSA.KeyPair (ECC.getCurveByName ECC.SEC_p256r1) (vapidKeyPoint) (privateNumber vapidKeysMin)
vapidKeyPoint = ECC.Point (publicCoordX vapidKeysMin) (publicCoordY vapidKeysMin)
privateKey = ECDSA.toPrivateKey $ unVAPIDKeys vapidKeys
vapidKeys = vapidConfigKeys vapidConfig

-- | Internal function to send a single push notification
sendPushNotification' :: (MonadIO m, A.ToJSON msg, MonadRandom m)
Expand Down
24 changes: 10 additions & 14 deletions src/Web/WebPush/Internal.hs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

module Web.WebPush.Internal where

import Control.Monad.IO.Class
import Crypto.Cipher.AES (AES128)
import qualified Crypto.Cipher.Types as Cipher
import qualified Crypto.ECC
Expand All @@ -28,16 +29,11 @@ import qualified Data.ByteString.Lazy as LB
import qualified Data.ByteString.Lazy.Base64.URL as B64.URL
import Data.Data
import Data.Text (Text)
import qualified Data.Text as T
import Data.Word (Word16, Word64, Word8)
import GHC.Int (Int64)
import qualified Data.Text as T
import Network.URI
import Control.Monad.IO.Class
import Network.HTTP.Types

newtype VAPIDKeys = VAPIDKeys {
unVAPIDKeys :: ECDSA.KeyPair
}
import Network.HTTP.Types
import Network.URI

-- | Server identification for a single host, used to identify the server to the remote push server
data ServerIdentification = ServerIdentification {
Expand All @@ -58,11 +54,11 @@ instance A.ToJSON ServerIdentification where
-- Manual implementation without using the JWT libraries.
-- Not using jose library. Check the below link for reason:
-- https://github.com/sarthakbagaria/web-push/pull/1#issuecomment-471254455
webPushJWT :: MonadRandom m => VAPIDKeys -> ServerIdentification -> m BS.ByteString
webPushJWT vapidKeys payload = do
webPushJWT :: MonadRandom m => ECDSA.PrivateKey -> ServerIdentification -> m BS.ByteString
webPushJWT privateKey payload = do
-- JWT only accepts SHA256 hash with ECDSA for ES256 signed token
-- ECDSA signing vulnerable to timing attacks
signature <- ECDSA.sign (ECDSA.toPrivateKey $ unVAPIDKeys vapidKeys) SHA256 jwtMessage
signature <- ECDSA.sign privateKey SHA256 jwtMessage
pure $ jwtMessage <> "." <> jwtSignature signature
where
jwtSignature (ECDSA.Signature signR signS) =
Expand Down Expand Up @@ -153,11 +149,11 @@ webPushEncrypt EncryptionInput{..} = do -- TODO remove record wildcards
-- | Authorization header for a vapid push notification request
-- this is shared between all push notifications sent to a single push service host
hostHeaders :: (MonadIO m, MonadRandom m)
=> VAPIDKeys
=> ECDSA.PrivateKey
-> ServerIdentification
-> m [Header]
hostHeaders vapidKeys serverIdentification = do
jwt <- webPushJWT vapidKeys serverIdentification
hostHeaders privateKey serverIdentification = do
jwt <- webPushJWT privateKey serverIdentification
pure [(hAuthorization, "WebPush " <> jwt)]

-- | The host for URI including scheme and port
Expand Down
173 changes: 173 additions & 0 deletions src/Web/WebPush/Keys.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}

module Web.WebPush.Keys where

import Web.WebPush.Internal

import Control.Exception
import qualified Crypto.ECC
import qualified Crypto.Number.Serialize as Serialize
import qualified Crypto.PubKey.ECC.ECDSA as ECDSA
import qualified Crypto.PubKey.ECC.Generate as ECC
import qualified Crypto.PubKey.ECC.Types as ECC
import Crypto.Random (MonadRandom)
import qualified Data.ASN1.BinaryEncoding as ASN1
import qualified Data.ASN1.Encoding as ASN1
import Data.ASN1.Error
import qualified Data.ASN1.Types as ASN1
import Data.Bifunctor
import qualified Data.ByteString as BS
import Data.PEM
import Data.Proxy
import Data.Word (Word8)
import Data.X509
import Data.X509.EC
import Data.X509.File

-- | VAPIDKeys are the public and private keys used to sign the JWT
-- authentication token sent for the push sendPushNotification
--
-- The key is an ECDSA key pair with the p256 curve
newtype VAPIDKeys = VAPIDKeys {
unVAPIDKeys :: ECDSA.KeyPair
} deriving (Show)

-- | Errors from reading the VAPID keys from files
data VAPIDKeysError =
VAPIDKeysPublicKeyError PublicKeyError -- ^ Error reading the public key
| VAPIDKeysPrivateKeyError PrivateKeyError -- ^ Error reading the private key
| VAPIDKeysCurveMismatch -- ^ The public and private keys are not on the same curve
deriving (Show)

-- | Read the public and private keys from files
readVapidKeys :: FilePath -- ^ Path to the public key file
-> FilePath -- ^ Path to the private key file
-> IO (Either VAPIDKeysError VAPIDKeys)
readVapidKeys pubKeyPath privKeyPath = do
pubKey <- readWebPushPublicKey pubKeyPath
privKey <- readWebPushPrivateKey privKeyPath
pure $ do
pub <- first VAPIDKeysPublicKeyError pubKey
priv <- first VAPIDKeysPrivateKeyError privKey
if ECDSA.public_curve pub /= ECDSA.private_curve priv
then Left VAPIDKeysCurveMismatch
else Right $ VAPIDKeys $ toKeyPair pub priv

-- | Convert public and private keys to a key pair
toKeyPair :: ECDSA.PublicKey -> ECDSA.PrivateKey -> ECDSA.KeyPair
toKeyPair pub priv = ECDSA.KeyPair (ECDSA.public_curve pub) (ECDSA.public_q pub) (ECDSA.private_d priv)

-- | Errors from reading the VAPID private key from files
data PrivateKeyError =
PrivateKeyPEMParseError PEMError -- ^ Error parsing the PEM file
| PrivateKeyUnknownCurveName -- ^ The curve name is not known
| PrivateKeyWrongCurve ECC.CurveName -- ^ The curve is not p256
| PrivateKeyInvalidPEM -- ^ The PEM file is not a single private key
deriving (Show)

-- | Read the private key from a PEM file
--
-- The private key is an ECDSA private number on the p256 curve
readWebPushPrivateKey :: FilePath -> IO (Either PrivateKeyError ECDSA.PrivateKey)
readWebPushPrivateKey fp = do
keys <- catch (Right <$> readKeyFile fp) (pure . Left . PrivateKeyPEMParseError)
pure $ toECDSAPrivateKey =<< findleSingleKey =<< keys
where
findleSingleKey [PrivKeyEC key] = Right key
findleSingleKey _ = Left PrivateKeyInvalidPEM
toECDSAPrivateKey privKey = do
curveName <- maybe (Left PrivateKeyUnknownCurveName) Right $ ecPrivKeyCurveName privKey
case curveName of
ECC.SEC_p256r1 -> do
let curve = ECC.getCurveByName curveName
pure $ ECDSA.PrivateKey curve (privkeyEC_priv privKey)
other -> Left $ PrivateKeyWrongCurve other

-- | Errors from reading the VAPID public key from files
data PublicKeyError =
PublicKeyPEMParseError PEMError -- ^ PEM encoding error
| PublicKeyASN1Error ASN1Error -- ^ ASN1 decoding error
| PublicKeyFromASN1Error String -- ^ Error converting ASN1 to ECDSA public key
| PublicKeyUnsupportedKeyType -- ^ The key type is not supported
| PublicKeyUnknownCurve -- ^ The curve is not known
| PublicKeyUnserialiseError -- ^ Error unserialising the EC point
| PublicKeyInvalidPEM -- ^ The PEM file is not a single public key
deriving (Show)

-- | Read the public key from a PEM file
--
-- The public key is an ECDSA public point on the p256 curve
readWebPushPublicKey :: FilePath -> IO (Either PublicKeyError ECDSA.PublicKey)
readWebPushPublicKey fp = do
contents <- BS.readFile fp
pubKey <- parsePEMPubKey contents
pure $ toECDSAPubKey =<< pubKey
where
ecPubKey (PubKeyEC pubKey) = Right pubKey
ecPubKey _ = Left PublicKeyUnsupportedKeyType
parsePEMPubKey str =
case pemParseBS str of
Left err -> fail err
Right [pem] -> pure $ do
as <- first PublicKeyASN1Error $ ASN1.decodeASN1' ASN1.DER $ pemContent pem
(key, _) <- first PublicKeyFromASN1Error $ ASN1.fromASN1 as
ecPubKey key
Right _ -> pure $ Left PublicKeyInvalidPEM
toECDSAPubKey pubKey = do
curve <- maybe (Left PublicKeyUnknownCurve) Right $ ECC.getCurveByName <$> ecPubKeyCurveName pubKey
point <- maybe (Left PublicKeyUnserialiseError) Right $ unserializePoint curve $ pubkeyEC_pub pubKey
pure $ ECDSA.PublicKey curve point

-- | Write the public and private keys to files
-- NOTE: This will overwrite any existing files and it does not
-- store keys in the exact same format as they were read in from
-- if they were created with OpenSSL
writeVAPIDKeys :: FilePath -> FilePath -> VAPIDKeys -> IO ()
writeVAPIDKeys pubKeyPath privKeyPath (VAPIDKeys keyPair) = do
writeKeyPEM pubKeyPath "PUBLIC KEY" $ toPubKey $ ECDSA.toPublicKey keyPair
writeKeyPEM privKeyPath "EC PRIVATE KEY" $ toPrivKey $ ECDSA.toPrivateKey keyPair
where
writeKeyPEM path name = BS.writeFile path . pemWriteBS . PEM name [] . encodeASN1
encodeASN1 key = ASN1.encodeASN1' ASN1.DER $ ASN1.toASN1 key []

toPubKey :: ECDSA.PublicKey -> PubKey
toPubKey = PubKeyEC . PubKeyEC_Named ECC.SEC_p256r1 . serializePoint . ECDSA.public_q

toPrivKey :: ECDSA.PrivateKey -> PrivKey
toPrivKey = PrivKeyEC . PrivKeyEC_Named ECC.SEC_p256r1 . ECDSA.private_d


serializePoint :: ECC.Point -> SerializedPoint
serializePoint ECC.PointO = error "can't serialize EC point at infinity"
serializePoint (ECC.Point x y) =
SerializedPoint $ BS.pack [4] <> Serialize.i2ospOf_ bytes x <> Serialize.i2ospOf_ bytes y
where
bits = Crypto.ECC.curveSizeBits (Proxy :: Proxy Crypto.ECC.Curve_P256R1)
bytes = (bits + 7) `div` 8

-- | Generate a new VAPID key pair, this is an ECDSA key pair on the p256 curve
--
-- Store them securely and use them across multiple push notification requests.
generateVAPIDKeys :: MonadRandom m => m (Either String VAPIDKeys)
generateVAPIDKeys = do
-- SEC_p256r1 is the NIST P-256
(pubKey, privKey) <- ECC.generate $ ECC.getCurveByName ECC.SEC_p256r1
pure $ case ECDSA.public_q pubKey of
ECC.PointO -> Left "Invalid public key generated, public_q is the point at infinity"
ECC.Point _ _ -> Right $ VAPIDKeys $ toKeyPair pubKey privKey

-- | Pass the VAPID public key bytes as `applicationServerKey` when calling subscribe
-- on the `PushManager` object on a registered service worker
--
-- > applicationServerKey = new Uint8Array( #{toJSON vapidPublicKeyBytes} )
vapidPublicKeyBytes :: VAPIDKeys -> Either String [Word8]
vapidPublicKeyBytes key =
case ECDSA.public_q $ ECDSA.toPublicKey $ unVAPIDKeys key of
ECC.PointO -> Left "Invalid public key generated, public_q is the point at infinity"
ECC.Point x y -> Right $ BS.unpack $ ecPublicKeyToBytes' (x, y)
2 changes: 1 addition & 1 deletion test/WebPushMock.hs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ testSendMessage =
_ <- webPushStatus

keys <- either fail pure =<< generateVAPIDKeys
let publicKeyBytes = vapidPublicKeyBytes keys
publicKeyBytes <- either fail pure $ vapidPublicKeyBytes keys
let subscriptionOptions = SubscribeOptions True $ BS.pack publicKeyBytes

step "Creating a test subscription to the mock server"
Expand Down

0 comments on commit 33d1478

Please sign in to comment.