diff options
Diffstat (limited to 'vendor/minishlink/web-push/src')
-rw-r--r-- | vendor/minishlink/web-push/src/Encryption.php | 321 | ||||
-rw-r--r-- | vendor/minishlink/web-push/src/MessageSentReport.php | 181 | ||||
-rw-r--r-- | vendor/minishlink/web-push/src/Notification.php | 86 | ||||
-rw-r--r-- | vendor/minishlink/web-push/src/Subscription.php | 122 | ||||
-rw-r--r-- | vendor/minishlink/web-push/src/SubscriptionInterface.php | 40 | ||||
-rw-r--r-- | vendor/minishlink/web-push/src/Utils.php | 63 | ||||
-rw-r--r-- | vendor/minishlink/web-push/src/VAPID.php | 197 | ||||
-rw-r--r-- | vendor/minishlink/web-push/src/WebPush.php | 412 |
8 files changed, 1422 insertions, 0 deletions
diff --git a/vendor/minishlink/web-push/src/Encryption.php b/vendor/minishlink/web-push/src/Encryption.php new file mode 100644 index 0000000..e9fe1ac --- /dev/null +++ b/vendor/minishlink/web-push/src/Encryption.php @@ -0,0 +1,321 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the WebPush library. + * + * (c) Louis Lagrange <lagrange.louis@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Minishlink\WebPush; + +use Base64Url\Base64Url; +use Jose\Component\Core\Util\Ecc\NistCurve; +use Jose\Component\Core\Util\Ecc\Point; +use Jose\Component\Core\Util\Ecc\PrivateKey; +use Jose\Component\Core\Util\Ecc\PublicKey; + +class Encryption +{ + public const MAX_PAYLOAD_LENGTH = 4078; + public const MAX_COMPATIBILITY_PAYLOAD_LENGTH = 3052; + + /** + * @param string $payload + * @param int $maxLengthToPad + * @param string $contentEncoding + * @return string padded payload (plaintext) + * @throws \ErrorException + */ + public static function padPayload(string $payload, int $maxLengthToPad, string $contentEncoding): string + { + $payloadLen = Utils::safeStrlen($payload); + $padLen = $maxLengthToPad ? $maxLengthToPad - $payloadLen : 0; + + if ($contentEncoding === "aesgcm") { + return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT); + } elseif ($contentEncoding === "aes128gcm") { + return str_pad($payload.chr(2), $padLen + $payloadLen, chr(0), STR_PAD_RIGHT); + } else { + throw new \ErrorException("This content encoding is not supported"); + } + } + + /** + * @param string $payload With padding + * @param string $userPublicKey Base 64 encoded (MIME or URL-safe) + * @param string $userAuthToken Base 64 encoded (MIME or URL-safe) + * @param string $contentEncoding + * @return array + * + * @throws \ErrorException + */ + public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding): array + { + return self::deterministicEncrypt( + $payload, + $userPublicKey, + $userAuthToken, + $contentEncoding, + self::createLocalKeyObject(), + random_bytes(16) + ); + } + + /** + * @param string $payload + * @param string $userPublicKey + * @param string $userAuthToken + * @param string $contentEncoding + * @param array $localKeyObject + * @param string $salt + * @return array + * + * @throws \ErrorException + */ + public static function deterministicEncrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding, array $localKeyObject, string $salt): array + { + $userPublicKey = Base64Url::decode($userPublicKey); + $userAuthToken = Base64Url::decode($userAuthToken); + + $curve = NistCurve::curve256(); + + // get local key pair + list($localPublicKeyObject, $localPrivateKeyObject) = $localKeyObject; + $localPublicKey = hex2bin(Utils::serializePublicKey($localPublicKeyObject)); + if (!$localPublicKey) { + throw new \ErrorException('Failed to convert local public key from hexadecimal to binary'); + } + + // get user public key object + [$userPublicKeyObjectX, $userPublicKeyObjectY] = Utils::unserializePublicKey($userPublicKey); + $userPublicKeyObject = $curve->getPublicKeyFrom( + gmp_init(bin2hex($userPublicKeyObjectX), 16), + gmp_init(bin2hex($userPublicKeyObjectY), 16) + ); + + // get shared secret from user public key and local private key + $sharedSecret = $curve->mul($userPublicKeyObject->getPoint(), $localPrivateKeyObject->getSecret())->getX(); + $sharedSecret = hex2bin(str_pad(gmp_strval($sharedSecret, 16), 64, '0', STR_PAD_LEFT)); + if (!$sharedSecret) { + throw new \ErrorException('Failed to convert shared secret from hexadecimal to binary'); + } + + // section 4.3 + $ikm = self::getIKM($userAuthToken, $userPublicKey, $localPublicKey, $sharedSecret, $contentEncoding); + + // section 4.2 + $context = self::createContext($userPublicKey, $localPublicKey, $contentEncoding); + + // derive the Content Encryption Key + $contentEncryptionKeyInfo = self::createInfo($contentEncoding, $context, $contentEncoding); + $contentEncryptionKey = self::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16); + + // section 3.3, derive the nonce + $nonceInfo = self::createInfo('nonce', $context, $contentEncoding); + $nonce = self::hkdf($salt, $ikm, $nonceInfo, 12); + + // encrypt + // "The additional data passed to each invocation of AEAD_AES_128_GCM is a zero-length octet sequence." + $tag = ''; + $encryptedText = openssl_encrypt($payload, 'aes-128-gcm', $contentEncryptionKey, OPENSSL_RAW_DATA, $nonce, $tag); + + // return values in url safe base64 + return [ + 'localPublicKey' => $localPublicKey, + 'salt' => $salt, + 'cipherText' => $encryptedText.$tag, + ]; + } + + public static function getContentCodingHeader($salt, $localPublicKey, $contentEncoding): string + { + if ($contentEncoding === "aes128gcm") { + return $salt + .pack('N*', 4096) + .pack('C*', Utils::safeStrlen($localPublicKey)) + .$localPublicKey; + } + + return ""; + } + + /** + * HMAC-based Extract-and-Expand Key Derivation Function (HKDF). + * + * This is used to derive a secure encryption key from a mostly-secure shared + * secret. + * + * This is a partial implementation of HKDF tailored to our specific purposes. + * In particular, for us the value of N will always be 1, and thus T always + * equals HMAC-Hash(PRK, info | 0x01). + * + * See {@link https://www.rfc-editor.org/rfc/rfc5869.txt} + * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js} + * + * @param string $salt A non-secret random value + * @param string $ikm Input keying material + * @param string $info Application-specific context + * @param int $length The length (in bytes) of the required output key + * + * @return string + */ + private static function hkdf(string $salt, string $ikm, string $info, int $length): string + { + // extract + $prk = hash_hmac('sha256', $ikm, $salt, true); + + // expand + return mb_substr(hash_hmac('sha256', $info.chr(1), $prk, true), 0, $length, '8bit'); + } + + /** + * Creates a context for deriving encryption parameters. + * See section 4.2 of + * {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00} + * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}. + * + * @param string $clientPublicKey The client's public key + * @param string $serverPublicKey Our public key + * + * @return null|string + * + * @throws \ErrorException + */ + private static function createContext(string $clientPublicKey, string $serverPublicKey, $contentEncoding): ?string + { + if ($contentEncoding === "aes128gcm") { + return null; + } + + if (Utils::safeStrlen($clientPublicKey) !== 65) { + throw new \ErrorException('Invalid client public key length'); + } + + // This one should never happen, because it's our code that generates the key + if (Utils::safeStrlen($serverPublicKey) !== 65) { + throw new \ErrorException('Invalid server public key length'); + } + + $len = chr(0).'A'; // 65 as Uint16BE + + return chr(0).$len.$clientPublicKey.$len.$serverPublicKey; + } + + /** + * Returns an info record. See sections 3.2 and 3.3 of + * {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00} + * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}. + * + * @param string $type The type of the info record + * @param string|null $context The context for the record + * @param string $contentEncoding + * @return string + * + * @throws \ErrorException + */ + private static function createInfo(string $type, ?string $context, string $contentEncoding): string + { + if ($contentEncoding === "aesgcm") { + if (!$context) { + throw new \ErrorException('Context must exist'); + } + + if (Utils::safeStrlen($context) !== 135) { + throw new \ErrorException('Context argument has invalid size'); + } + + return 'Content-Encoding: '.$type.chr(0).'P-256'.$context; + } elseif ($contentEncoding === "aes128gcm") { + return 'Content-Encoding: '.$type.chr(0); + } + + throw new \ErrorException('This content encoding is not supported.'); + } + + /** + * @return array + */ + private static function createLocalKeyObject(): array + { + try { + return self::createLocalKeyObjectUsingOpenSSL(); + } catch (\Exception $e) { + return self::createLocalKeyObjectUsingPurePhpMethod(); + } + } + + /** + * @return array + */ + private static function createLocalKeyObjectUsingPurePhpMethod(): array + { + $curve = NistCurve::curve256(); + $privateKey = $curve->createPrivateKey(); + + return [ + $curve->createPublicKey($privateKey), + $privateKey, + ]; + } + + /** + * @return array + */ + private static function createLocalKeyObjectUsingOpenSSL(): array + { + $keyResource = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + + if (!$keyResource) { + throw new \RuntimeException('Unable to create the key'); + } + + $details = openssl_pkey_get_details($keyResource); + openssl_pkey_free($keyResource); + + if (!$details) { + throw new \RuntimeException('Unable to get the key details'); + } + + return [ + PublicKey::create(Point::create( + gmp_init(bin2hex($details['ec']['x']), 16), + gmp_init(bin2hex($details['ec']['y']), 16) + )), + PrivateKey::create(gmp_init(bin2hex($details['ec']['d']), 16)) + ]; + } + + /** + * @param string $userAuthToken + * @param string $userPublicKey + * @param string $localPublicKey + * @param string $sharedSecret + * @param string $contentEncoding + * @return string + * @throws \ErrorException + */ + private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, string $contentEncoding): string + { + if (!empty($userAuthToken)) { + if ($contentEncoding === "aesgcm") { + $info = 'Content-Encoding: auth'.chr(0); + } elseif ($contentEncoding === "aes128gcm") { + $info = "WebPush: info".chr(0).$userPublicKey.$localPublicKey; + } else { + throw new \ErrorException("This content encoding is not supported"); + } + + return self::hkdf($userAuthToken, $sharedSecret, $info, 32); + } + + return $sharedSecret; + } +} diff --git a/vendor/minishlink/web-push/src/MessageSentReport.php b/vendor/minishlink/web-push/src/MessageSentReport.php new file mode 100644 index 0000000..c569952 --- /dev/null +++ b/vendor/minishlink/web-push/src/MessageSentReport.php @@ -0,0 +1,181 @@ +<?php +/** + * @author Igor Timoshenkov [it@campoint.net] + * @started: 03.09.2018 9:21 + */ + +namespace Minishlink\WebPush; + +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; + +/** + * Standardized response from sending a message + */ +class MessageSentReport implements \JsonSerializable +{ + + /** + * @var boolean + */ + protected $success; + + /** + * @var RequestInterface + */ + protected $request; + + /** + * @var ResponseInterface | null + */ + protected $response; + + /** + * @var string + */ + protected $reason; + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @param bool $success + * @param string $reason + */ + public function __construct(RequestInterface $request, ?ResponseInterface $response = null, bool $success = true, $reason = 'OK') + { + $this->request = $request; + $this->response = $response; + $this->success = $success; + $this->reason = $reason; + } + + /** + * @return bool + */ + public function isSuccess(): bool + { + return $this->success; + } + + /** + * @param bool $success + * + * @return MessageSentReport + */ + public function setSuccess(bool $success): MessageSentReport + { + $this->success = $success; + return $this; + } + + /** + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } + + /** + * @param RequestInterface $request + * + * @return MessageSentReport + */ + public function setRequest(RequestInterface $request): MessageSentReport + { + $this->request = $request; + return $this; + } + + /** + * @return ResponseInterface | null + */ + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + /** + * @param ResponseInterface $response + * + * @return MessageSentReport + */ + public function setResponse(ResponseInterface $response): MessageSentReport + { + $this->response = $response; + return $this; + } + + /** + * @return string + */ + public function getEndpoint(): string + { + return $this->request->getUri()->__toString(); + } + + /** + * @return bool + */ + public function isSubscriptionExpired(): bool + { + if (!$this->response) { + return false; + } + + return \in_array($this->response->getStatusCode(), [404, 410], true); + } + + /** + * @return string + */ + public function getReason(): string + { + return $this->reason; + } + + /** + * @param string $reason + * + * @return MessageSentReport + */ + public function setReason(string $reason): MessageSentReport + { + $this->reason = $reason; + return $this; + } + + /** + * @return string + */ + public function getRequestPayload(): string + { + return $this->request->getBody()->getContents(); + } + + /** + * @return string | null + */ + public function getResponseContent(): ?string + { + if (!$this->response) { + return null; + } + + return $this->response->getBody()->getContents(); + } + + /** + * @return array|mixed + */ + public function jsonSerialize() + { + return [ + 'success' => $this->isSuccess(), + 'expired' => $this->isSubscriptionExpired(), + 'reason' => $this->reason, + 'endpoint' => $this->getEndpoint(), + 'payload' => $this->request->getBody()->getContents(), + ]; + } +} diff --git a/vendor/minishlink/web-push/src/Notification.php b/vendor/minishlink/web-push/src/Notification.php new file mode 100644 index 0000000..1107404 --- /dev/null +++ b/vendor/minishlink/web-push/src/Notification.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the WebPush library. + * + * (c) Louis Lagrange <lagrange.louis@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Minishlink\WebPush; + +class Notification +{ + /** @var SubscriptionInterface */ + private $subscription; + + /** @var null|string */ + private $payload; + + /** @var array Options : TTL, urgency, topic */ + private $options; + + /** @var array Auth details : GCM, VAPID */ + private $auth; + + /** + * Notification constructor. + * + * @param SubscriptionInterface $subscription + * @param null|string $payload + * @param array $options + * @param array $auth + */ + public function __construct(SubscriptionInterface $subscription, ?string $payload, array $options, array $auth) + { + $this->subscription = $subscription; + $this->payload = $payload; + $this->options = $options; + $this->auth = $auth; + } + + /** + * @return SubscriptionInterface + */ + public function getSubscription(): SubscriptionInterface + { + return $this->subscription; + } + + /** + * @return null|string + */ + public function getPayload(): ?string + { + return $this->payload; + } + + /** + * @param array $defaultOptions + * + * @return array + */ + public function getOptions(array $defaultOptions = []): array + { + $options = $this->options; + $options['TTL'] = array_key_exists('TTL', $options) ? $options['TTL'] : $defaultOptions['TTL']; + $options['urgency'] = array_key_exists('urgency', $options) ? $options['urgency'] : $defaultOptions['urgency']; + $options['topic'] = array_key_exists('topic', $options) ? $options['topic'] : $defaultOptions['topic']; + + return $options; + } + + /** + * @param array $defaultAuth + * + * @return array + */ + public function getAuth(array $defaultAuth): array + { + return count($this->auth) > 0 ? $this->auth : $defaultAuth; + } +} diff --git a/vendor/minishlink/web-push/src/Subscription.php b/vendor/minishlink/web-push/src/Subscription.php new file mode 100644 index 0000000..1232893 --- /dev/null +++ b/vendor/minishlink/web-push/src/Subscription.php @@ -0,0 +1,122 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the WebPush library. + * + * (c) Louis Lagrange <lagrange.louis@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Minishlink\WebPush; + +class Subscription implements SubscriptionInterface +{ + /** @var string */ + private $endpoint; + + /** @var null|string */ + private $publicKey; + + /** @var null|string */ + private $authToken; + + /** @var null|string */ + private $contentEncoding; + + /** + * Subscription constructor. + * + * @param string $endpoint + * @param null|string $publicKey + * @param null|string $authToken + * @param string $contentEncoding (Optional) Must be "aesgcm" + * @throws \ErrorException + */ + public function __construct( + string $endpoint, + ?string $publicKey = null, + ?string $authToken = null, + ?string $contentEncoding = null + ) { + $this->endpoint = $endpoint; + + if ($publicKey || $authToken || $contentEncoding) { + $supportedContentEncodings = ['aesgcm', 'aes128gcm']; + if ($contentEncoding && !in_array($contentEncoding, $supportedContentEncodings)) { + throw new \ErrorException('This content encoding ('.$contentEncoding.') is not supported.'); + } + + $this->publicKey = $publicKey; + $this->authToken = $authToken; + $this->contentEncoding = $contentEncoding ?: "aesgcm"; + } + } + + /** + * Subscription factory. + * + * @param array $associativeArray (with keys endpoint, publicKey, authToken, contentEncoding) + * @return self + * @throws \ErrorException + */ + public static function create(array $associativeArray): self + { + if (array_key_exists('keys', $associativeArray) && is_array($associativeArray['keys'])) { + return new self( + $associativeArray['endpoint'], + $associativeArray['keys']['p256dh'] ?? null, + $associativeArray['keys']['auth'] ?? null, + $associativeArray['contentEncoding'] ?? "aesgcm" + ); + } + + if (array_key_exists('publicKey', $associativeArray) || array_key_exists('authToken', $associativeArray) || array_key_exists('contentEncoding', $associativeArray)) { + return new self( + $associativeArray['endpoint'], + $associativeArray['publicKey'] ?? null, + $associativeArray['authToken'] ?? null, + $associativeArray['contentEncoding'] ?? "aesgcm" + ); + } + + return new self( + $associativeArray['endpoint'] + ); + } + + /** + * {@inheritDoc} + */ + public function getEndpoint(): string + { + return $this->endpoint; + } + + /** + * {@inheritDoc} + */ + public function getPublicKey(): ?string + { + return $this->publicKey; + } + + /** + * {@inheritDoc} + */ + public function getAuthToken(): ?string + { + return $this->authToken; + } + + /** + * {@inheritDoc} + */ + public function getContentEncoding(): ?string + { + return $this->contentEncoding; + } +} diff --git a/vendor/minishlink/web-push/src/SubscriptionInterface.php b/vendor/minishlink/web-push/src/SubscriptionInterface.php new file mode 100644 index 0000000..e3f18d6 --- /dev/null +++ b/vendor/minishlink/web-push/src/SubscriptionInterface.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the WebPush library. + * + * (c) Louis Lagrange <lagrange.louis@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Minishlink\WebPush; + +/** + * @author Sergii Bondarenko <sb@firstvector.org> + */ +interface SubscriptionInterface +{ + /** + * @return string + */ + public function getEndpoint(): string; + + /** + * @return null|string + */ + public function getPublicKey(): ?string; + + /** + * @return null|string + */ + public function getAuthToken(): ?string; + + /** + * @return null|string + */ + public function getContentEncoding(): ?string; +} diff --git a/vendor/minishlink/web-push/src/Utils.php b/vendor/minishlink/web-push/src/Utils.php new file mode 100644 index 0000000..30c2018 --- /dev/null +++ b/vendor/minishlink/web-push/src/Utils.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the WebPush library. + * + * (c) Louis Lagrange <lagrange.louis@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Minishlink\WebPush; + +use Jose\Component\Core\Util\Ecc\PublicKey; + +class Utils +{ + /** + * @param string $value + * + * @return int + */ + public static function safeStrlen(string $value): int + { + return mb_strlen($value, '8bit'); + } + + /** + * @param PublicKey $publicKey + * + * @return string + */ + public static function serializePublicKey(PublicKey $publicKey): string + { + $hexString = '04'; + $hexString .= str_pad(gmp_strval($publicKey->getPoint()->getX(), 16), 64, '0', STR_PAD_LEFT); + $hexString .= str_pad(gmp_strval($publicKey->getPoint()->getY(), 16), 64, '0', STR_PAD_LEFT); + + return $hexString; + } + + /** + * @param string $data + * + * @return array + */ + public static function unserializePublicKey(string $data): array + { + $data = bin2hex($data); + if (mb_substr($data, 0, 2, '8bit') !== '04') { + throw new \InvalidArgumentException('Invalid data: only uncompressed keys are supported.'); + } + $data = mb_substr($data, 2, null, '8bit'); + $dataLength = self::safeStrlen($data); + + return [ + hex2bin(mb_substr($data, 0, $dataLength / 2, '8bit')), + hex2bin(mb_substr($data, $dataLength / 2, null, '8bit')), + ]; + } +} diff --git a/vendor/minishlink/web-push/src/VAPID.php b/vendor/minishlink/web-push/src/VAPID.php new file mode 100644 index 0000000..c741ec9 --- /dev/null +++ b/vendor/minishlink/web-push/src/VAPID.php @@ -0,0 +1,197 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the WebPush library. + * + * (c) Louis Lagrange <lagrange.louis@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Minishlink\WebPush; + +use Base64Url\Base64Url; +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\Core\Converter\StandardConverter; +use Jose\Component\Core\JWK; +use Jose\Component\Core\Util\Ecc\NistCurve; +use Jose\Component\Core\Util\Ecc\Point; +use Jose\Component\Core\Util\Ecc\PublicKey; +use Jose\Component\KeyManagement\JWKFactory; +use Jose\Component\Signature\Algorithm\ES256; +use Jose\Component\Signature\JWSBuilder; +use Jose\Component\Signature\Serializer\CompactSerializer; + +class VAPID +{ + private const PUBLIC_KEY_LENGTH = 65; + private const PRIVATE_KEY_LENGTH = 32; + + /** + * @param array $vapid + * + * @return array + * + * @throws \ErrorException + */ + public static function validate(array $vapid): array + { + if (!isset($vapid['subject'])) { + throw new \ErrorException('[VAPID] You must provide a subject that is either a mailto: or a URL.'); + } + + if (isset($vapid['pemFile'])) { + $vapid['pem'] = file_get_contents($vapid['pemFile']); + + if (!$vapid['pem']) { + throw new \ErrorException('Error loading PEM file.'); + } + } + + if (isset($vapid['pem'])) { + $jwk = JWKFactory::createFromKey($vapid['pem']); + if ($jwk->get('kty') !== 'EC' || !$jwk->has('d') || !$jwk->has('x') || !$jwk->has('y')) { + throw new \ErrorException('Invalid PEM data.'); + } + $publicKey = PublicKey::create(Point::create( + gmp_init(bin2hex(Base64Url::decode($jwk->get('x'))), 16), + gmp_init(bin2hex(Base64Url::decode($jwk->get('y'))), 16) + )); + + $binaryPublicKey = hex2bin(Utils::serializePublicKey($publicKey)); + if (!$binaryPublicKey) { + throw new \ErrorException('Failed to convert VAPID public key from hexadecimal to binary'); + } + $vapid['publicKey'] = base64_encode($binaryPublicKey); + $vapid['privateKey'] = base64_encode(str_pad(Base64Url::decode($jwk->get('d')), 2 * self::PRIVATE_KEY_LENGTH, '0', STR_PAD_LEFT)); + } + + if (!isset($vapid['publicKey'])) { + throw new \ErrorException('[VAPID] You must provide a public key.'); + } + + $publicKey = Base64Url::decode($vapid['publicKey']); + + if (Utils::safeStrlen($publicKey) !== self::PUBLIC_KEY_LENGTH) { + throw new \ErrorException('[VAPID] Public key should be 65 bytes long when decoded.'); + } + + if (!isset($vapid['privateKey'])) { + throw new \ErrorException('[VAPID] You must provide a private key.'); + } + + $privateKey = Base64Url::decode($vapid['privateKey']); + + if (Utils::safeStrlen($privateKey) !== self::PRIVATE_KEY_LENGTH) { + throw new \ErrorException('[VAPID] Private key should be 32 bytes long when decoded.'); + } + + return [ + 'subject' => $vapid['subject'], + 'publicKey' => $publicKey, + 'privateKey' => $privateKey, + ]; + } + + /** + * This method takes the required VAPID parameters and returns the required + * header to be added to a Web Push Protocol Request. + * + * @param string $audience This must be the origin of the push service + * @param string $subject This should be a URL or a 'mailto:' email address + * @param string $publicKey The decoded VAPID public key + * @param string $privateKey The decoded VAPID private key + * @param string $contentEncoding + * @param null|int $expiration The expiration of the VAPID JWT. (UNIX timestamp) + * + * @return array Returns an array with the 'Authorization' and 'Crypto-Key' values to be used as headers + * @throws \ErrorException + */ + public static function getVapidHeaders(string $audience, string $subject, string $publicKey, string $privateKey, string $contentEncoding, ?int $expiration = null) + { + $expirationLimit = time() + 43200; // equal margin of error between 0 and 24h + if (null === $expiration || $expiration > $expirationLimit) { + $expiration = $expirationLimit; + } + + $header = [ + 'typ' => 'JWT', + 'alg' => 'ES256', + ]; + + $jwtPayload = json_encode([ + 'aud' => $audience, + 'exp' => $expiration, + 'sub' => $subject, + ], JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK); + if (!$jwtPayload) { + throw new \ErrorException('Failed to encode JWT payload in JSON'); + } + + list($x, $y) = Utils::unserializePublicKey($publicKey); + $jwk = JWK::create([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => Base64Url::encode($x), + 'y' => Base64Url::encode($y), + 'd' => Base64Url::encode($privateKey), + ]); + + $jsonConverter = new StandardConverter(); + $jwsCompactSerializer = new CompactSerializer($jsonConverter); + $jwsBuilder = new JWSBuilder($jsonConverter, AlgorithmManager::create([new ES256()])); + $jws = $jwsBuilder + ->create() + ->withPayload($jwtPayload) + ->addSignature($jwk, $header) + ->build(); + + $jwt = $jwsCompactSerializer->serialize($jws, 0); + $encodedPublicKey = Base64Url::encode($publicKey); + + if ($contentEncoding === "aesgcm") { + return [ + 'Authorization' => 'WebPush '.$jwt, + 'Crypto-Key' => 'p256ecdsa='.$encodedPublicKey, + ]; + } elseif ($contentEncoding === 'aes128gcm') { + return [ + 'Authorization' => 'vapid t='.$jwt.', k='.$encodedPublicKey, + ]; + } + + throw new \ErrorException('This content encoding is not supported'); + } + + /** + * This method creates VAPID keys in case you would not be able to have a Linux bash. + * DO NOT create keys at each initialization! Save those keys and reuse them. + * + * @return array + * @throws \ErrorException + */ + public static function createVapidKeys(): array + { + $curve = NistCurve::curve256(); + $privateKey = $curve->createPrivateKey(); + $publicKey = $curve->createPublicKey($privateKey); + + $binaryPublicKey = hex2bin(Utils::serializePublicKey($publicKey)); + if (!$binaryPublicKey) { + throw new \ErrorException('Failed to convert VAPID public key from hexadecimal to binary'); + } + + $binaryPrivateKey = hex2bin(str_pad(gmp_strval($privateKey->getSecret(), 16), 2 * self::PRIVATE_KEY_LENGTH, '0', STR_PAD_LEFT)); + if (!$binaryPrivateKey) { + throw new \ErrorException('Failed to convert VAPID private key from hexadecimal to binary'); + } + + return [ + 'publicKey' => Base64Url::encode($binaryPublicKey), + 'privateKey' => Base64Url::encode($binaryPrivateKey) + ]; + } +} diff --git a/vendor/minishlink/web-push/src/WebPush.php b/vendor/minishlink/web-push/src/WebPush.php new file mode 100644 index 0000000..aaa9b4b --- /dev/null +++ b/vendor/minishlink/web-push/src/WebPush.php @@ -0,0 +1,412 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the WebPush library. + * + * (c) Louis Lagrange <lagrange.louis@gmail.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Minishlink\WebPush; + +use Base64Url\Base64Url; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Psr7\Request; +use Psr\Http\Message\ResponseInterface; + +class WebPush +{ + public const GCM_URL = 'https://android.googleapis.com/gcm/send'; + public const FCM_BASE_URL = 'https://fcm.googleapis.com'; + + /** + * @var Client + */ + private $client; + + /** + * @var array + */ + private $auth; + + /** + * @var null|array Array of array of Notifications + */ + private $notifications; + + /** + * @var array Default options : TTL, urgency, topic, batchSize + */ + private $defaultOptions; + + /** + * @var int Automatic padding of payloads, if disabled, trade security for bandwidth + */ + private $automaticPadding = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH; + + /** + * @var bool Reuse VAPID headers in the same flush session to improve performance + */ + private $reuseVAPIDHeaders = false; + + /** + * @var array Dictionary for VAPID headers cache + */ + private $vapidHeaders = []; + + /** + * WebPush constructor. + * + * @param array $auth Some servers needs authentication + * @param array $defaultOptions TTL, urgency, topic, batchSize + * @param int|null $timeout Timeout of POST request + * @param array $clientOptions + * + * @throws \ErrorException + */ + public function __construct(array $auth = [], array $defaultOptions = [], ?int $timeout = 30, array $clientOptions = []) + { + $extensions = [ + 'curl' => '[WebPush] curl extension is not loaded but is required. You can fix this in your php.ini.', + 'gmp' => '[WebPush] gmp extension is not loaded but is required for sending push notifications with payload or for VAPID authentication. You can fix this in your php.ini.', + 'mbstring' => '[WebPush] mbstring extension is not loaded but is required for sending push notifications with payload or for VAPID authentication. You can fix this in your php.ini.', + 'openssl' => '[WebPush] openssl extension is not loaded but is required for sending push notifications with payload or for VAPID authentication. You can fix this in your php.ini.', + ]; + foreach ($extensions as $extension => $message) { + if (!extension_loaded($extension)) { + trigger_error($message, E_USER_WARNING); + } + } + + if (ini_get('mbstring.func_overload') >= 2) { + trigger_error("[WebPush] mbstring.func_overload is enabled for str* functions. You must disable it if you want to send push notifications with payload or use VAPID. You can fix this in your php.ini.", E_USER_NOTICE); + } + + if (isset($auth['VAPID'])) { + $auth['VAPID'] = VAPID::validate($auth['VAPID']); + } + + $this->auth = $auth; + + $this->setDefaultOptions($defaultOptions); + + if (!array_key_exists('timeout', $clientOptions) && isset($timeout)) { + $clientOptions['timeout'] = $timeout; + } + $this->client = new Client($clientOptions); + } + + /** + * Send a notification. + * + * @param SubscriptionInterface $subscription + * @param string|null $payload If you want to send an array, json_encode it + * @param bool $flush If you want to flush directly (usually when you send only one notification) + * @param array $options Array with several options tied to this notification. If not set, will use the default options that you can set in the WebPush object + * @param array $auth Use this auth details instead of what you provided when creating WebPush + * + * @return \Generator|MessageSentReport[]|true Return an array of information if $flush is set to true and the queued requests has failed. + * Else return true + * + * @throws \ErrorException + */ + public function sendNotification(SubscriptionInterface $subscription, ?string $payload = null, bool $flush = false, array $options = [], array $auth = []) + { + if (isset($payload)) { + if (Utils::safeStrlen($payload) > Encryption::MAX_PAYLOAD_LENGTH) { + throw new \ErrorException('Size of payload must not be greater than '.Encryption::MAX_PAYLOAD_LENGTH.' octets.'); + } + + $contentEncoding = $subscription->getContentEncoding(); + if (!$contentEncoding) { + throw new \ErrorException('Subscription should have a content encoding'); + } + + $payload = Encryption::padPayload($payload, $this->automaticPadding, $contentEncoding); + } + + if (array_key_exists('VAPID', $auth)) { + $auth['VAPID'] = VAPID::validate($auth['VAPID']); + } + + $this->notifications[] = new Notification($subscription, $payload, $options, $auth); + + return false !== $flush ? $this->flush() : true; + } + + /** + * Flush notifications. Triggers the requests. + * + * @param null|int $batchSize Defaults the value defined in defaultOptions during instantiation (which defaults to 1000). + * + * @return \Generator|MessageSentReport[] + * @throws \ErrorException + */ + public function flush(?int $batchSize = null): \Generator + { + if (null === $this->notifications || empty($this->notifications)) { + yield from []; + return; + } + + if (null === $batchSize) { + $batchSize = $this->defaultOptions['batchSize']; + } + + $batches = array_chunk($this->notifications, $batchSize); + + // reset queue + $this->notifications = []; + + foreach ($batches as $batch) { + // for each endpoint server type + $requests = $this->prepare($batch); + + $promises = []; + + foreach ($requests as $request) { + $promises[] = $this->client->sendAsync($request) + ->then(function ($response) use ($request) { + /** @var ResponseInterface $response * */ + return new MessageSentReport($request, $response); + }) + ->otherwise(function ($reason) { + /** @var RequestException $reason **/ + return new MessageSentReport($reason->getRequest(), $reason->getResponse(), false, $reason->getMessage()); + }); + } + + foreach ($promises as $promise) { + yield $promise->wait(); + } + } + + if ($this->reuseVAPIDHeaders) { + $this->vapidHeaders = []; + } + } + + /** + * @param array $notifications + * + * @return array + * + * @throws \ErrorException + */ + private function prepare(array $notifications): array + { + $requests = []; + /** @var Notification $notification */ + foreach ($notifications as $notification) { + $subscription = $notification->getSubscription(); + $endpoint = $subscription->getEndpoint(); + $userPublicKey = $subscription->getPublicKey(); + $userAuthToken = $subscription->getAuthToken(); + $contentEncoding = $subscription->getContentEncoding(); + $payload = $notification->getPayload(); + $options = $notification->getOptions($this->getDefaultOptions()); + $auth = $notification->getAuth($this->auth); + + if (!empty($payload) && !empty($userPublicKey) && !empty($userAuthToken)) { + if (!$contentEncoding) { + throw new \ErrorException('Subscription should have a content encoding'); + } + + $encrypted = Encryption::encrypt($payload, $userPublicKey, $userAuthToken, $contentEncoding); + $cipherText = $encrypted['cipherText']; + $salt = $encrypted['salt']; + $localPublicKey = $encrypted['localPublicKey']; + + $headers = [ + 'Content-Type' => 'application/octet-stream', + 'Content-Encoding' => $contentEncoding, + ]; + + if ($contentEncoding === "aesgcm") { + $headers['Encryption'] = 'salt='.Base64Url::encode($salt); + $headers['Crypto-Key'] = 'dh='.Base64Url::encode($localPublicKey); + } + + $encryptionContentCodingHeader = Encryption::getContentCodingHeader($salt, $localPublicKey, $contentEncoding); + $content = $encryptionContentCodingHeader.$cipherText; + + $headers['Content-Length'] = Utils::safeStrlen($content); + } else { + $headers = [ + 'Content-Length' => 0, + ]; + + $content = ''; + } + + $headers['TTL'] = $options['TTL']; + + if (isset($options['urgency'])) { + $headers['Urgency'] = $options['urgency']; + } + + if (isset($options['topic'])) { + $headers['Topic'] = $options['topic']; + } + + // if GCM + if (substr($endpoint, 0, strlen(self::GCM_URL)) === self::GCM_URL) { + if (array_key_exists('GCM', $auth)) { + $headers['Authorization'] = 'key='.$auth['GCM']; + } else { + throw new \ErrorException('No GCM API Key specified.'); + } + } + // if VAPID (GCM doesn't support it but FCM does) + elseif (array_key_exists('VAPID', $auth) && $contentEncoding) { + $audience = parse_url($endpoint, PHP_URL_SCHEME).'://'.parse_url($endpoint, PHP_URL_HOST); + if (!parse_url($audience)) { + throw new \ErrorException('Audience "'.$audience.'"" could not be generated.'); + } + + $vapidHeaders = $this->getVAPIDHeaders($audience, $contentEncoding, $auth['VAPID']); + + $headers['Authorization'] = $vapidHeaders['Authorization']; + + if ($contentEncoding === 'aesgcm') { + if (array_key_exists('Crypto-Key', $headers)) { + $headers['Crypto-Key'] .= ';'.$vapidHeaders['Crypto-Key']; + } else { + $headers['Crypto-Key'] = $vapidHeaders['Crypto-Key']; + } + } + } + + $requests[] = new Request('POST', $endpoint, $headers, $content); + } + + return $requests; + } + + /** + * @return bool + */ + public function isAutomaticPadding(): bool + { + return $this->automaticPadding !== 0; + } + + /** + * @return int + */ + public function getAutomaticPadding() + { + return $this->automaticPadding; + } + + /** + * @param int|bool $automaticPadding Max padding length + * + * @return WebPush + * + * @throws \Exception + */ + public function setAutomaticPadding($automaticPadding): WebPush + { + if ($automaticPadding > Encryption::MAX_PAYLOAD_LENGTH) { + throw new \Exception('Automatic padding is too large. Max is '.Encryption::MAX_PAYLOAD_LENGTH.'. Recommended max is '.Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH.' for compatibility reasons (see README).'); + } elseif ($automaticPadding < 0) { + throw new \Exception('Padding length should be positive or zero.'); + } elseif ($automaticPadding === true) { + $this->automaticPadding = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH; + } elseif ($automaticPadding === false) { + $this->automaticPadding = 0; + } else { + $this->automaticPadding = $automaticPadding; + } + + return $this; + } + + /** + * @return bool + */ + public function getReuseVAPIDHeaders() + { + return $this->reuseVAPIDHeaders; + } + + /** + * Reuse VAPID headers in the same flush session to improve performance + * @param bool $enabled + * + * @return WebPush + */ + public function setReuseVAPIDHeaders(bool $enabled) + { + $this->reuseVAPIDHeaders = $enabled; + + return $this; + } + + /** + * @return array + */ + public function getDefaultOptions(): array + { + return $this->defaultOptions; + } + + /** + * @param array $defaultOptions Keys 'TTL' (Time To Live, defaults 4 weeks), 'urgency', 'topic', 'batchSize' + * + * @return WebPush + */ + public function setDefaultOptions(array $defaultOptions) + { + $this->defaultOptions['TTL'] = isset($defaultOptions['TTL']) ? $defaultOptions['TTL'] : 2419200; + $this->defaultOptions['urgency'] = isset($defaultOptions['urgency']) ? $defaultOptions['urgency'] : null; + $this->defaultOptions['topic'] = isset($defaultOptions['topic']) ? $defaultOptions['topic'] : null; + $this->defaultOptions['batchSize'] = isset($defaultOptions['batchSize']) ? $defaultOptions['batchSize'] : 1000; + + return $this; + } + + /** + * @return int + */ + public function countPendingNotifications(): int + { + return null !== $this->notifications ? count($this->notifications) : 0; + } + + /** + * @param string $audience + * @param string $contentEncoding + * @param array $vapid + * @return array + * @throws \ErrorException + */ + private function getVAPIDHeaders(string $audience, string $contentEncoding, array $vapid) + { + $vapidHeaders = null; + + $cache_key = null; + if ($this->reuseVAPIDHeaders) { + $cache_key = implode('#', [$audience, $contentEncoding, crc32(serialize($vapid))]); + if (array_key_exists($cache_key, $this->vapidHeaders)) { + $vapidHeaders = $this->vapidHeaders[$cache_key]; + } + } + + if (!$vapidHeaders) { + $vapidHeaders = VAPID::getVapidHeaders($audience, $vapid['subject'], $vapid['publicKey'], $vapid['privateKey'], $contentEncoding); + } + + if ($this->reuseVAPIDHeaders) { + $this->vapidHeaders[$cache_key] = $vapidHeaders; + } + + return $vapidHeaders; + } +} |