first commit
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
/**
|
||||
* Akeeba WebPush
|
||||
*
|
||||
* An abstraction layer for easier implementation of WebPush in Joomla components.
|
||||
*
|
||||
* @copyright (c) 2022 Akeeba Ltd
|
||||
* @license GNU GPL v3 or later; see LICENSE.txt
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Akeeba\WebPush\WebPush;
|
||||
|
||||
use Akeeba\WebPush\ECC\Curve;
|
||||
use Akeeba\WebPush\ECC\Point;
|
||||
use Akeeba\WebPush\ECC\PrivateKey;
|
||||
use Base64Url\Base64Url;
|
||||
use Brick\Math\BigInteger;
|
||||
use function mb_substr;
|
||||
use function chr;
|
||||
use function hex2bin;
|
||||
use function is_array;
|
||||
use function openssl_encrypt;
|
||||
use function pack;
|
||||
use function str_pad;
|
||||
use function unpack;
|
||||
use const false;
|
||||
use const OPENSSL_RAW_DATA;
|
||||
use const STR_PAD_LEFT;
|
||||
|
||||
/**
|
||||
* This class is a derivative work based on the WebPush library by Louis Lagrange. It has been modified to only use
|
||||
* dependencies shipped with Joomla itself and must not be confused with the original work.
|
||||
*
|
||||
* You can find the original code at https://github.com/web-push-libs
|
||||
*
|
||||
* The original code came with the following copyright notice:
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* 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-LAGRANGE.txt
|
||||
* file that was distributed with this source code.
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*/
|
||||
class Encryption
|
||||
{
|
||||
public const MAX_PAYLOAD_LENGTH = 4078;
|
||||
|
||||
public const MAX_COMPATIBILITY_PAYLOAD_LENGTH = 3052;
|
||||
|
||||
/**
|
||||
* @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)
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding): array
|
||||
{
|
||||
$localKeyData = self::createLocalKeyObjectUsingOpenSSL();
|
||||
$salt = random_bytes(16);
|
||||
|
||||
$userPublicKey = Base64Url::decode($userPublicKey);
|
||||
$userAuthToken = Base64Url::decode($userAuthToken);
|
||||
|
||||
// get local key pair
|
||||
$localPublicKey = hex2bin(Utils::serializePublicKeyFromData($localKeyData));
|
||||
|
||||
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);
|
||||
|
||||
$userKeyData = [
|
||||
'x' => $userPublicKeyObjectX,
|
||||
'y' => $userPublicKeyObjectY,
|
||||
];
|
||||
|
||||
// get shared secret from user public key and local private key
|
||||
$sharedSecret = Encryption::calculateAgreementKey($localKeyData, $userKeyData);
|
||||
|
||||
$sharedSecret = str_pad($sharedSecret, 32, chr(0), STR_PAD_LEFT);
|
||||
|
||||
// section 4.3
|
||||
$ikm = Encryption::getIKM($userAuthToken, $userPublicKey, $localPublicKey, $sharedSecret, $contentEncoding);
|
||||
|
||||
// section 4.2
|
||||
$context = Encryption::createContext($userPublicKey, $localPublicKey, $contentEncoding);
|
||||
|
||||
// derive the Content Encryption Key
|
||||
$contentEncryptionKeyInfo = Encryption::createInfo($contentEncoding, $context, $contentEncoding);
|
||||
$contentEncryptionKey = Encryption::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16);
|
||||
|
||||
// section 3.3, derive the nonce
|
||||
$nonceInfo = Encryption::createInfo('nonce', $context, $contentEncoding);
|
||||
$nonce = Encryption::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(string $salt, string $localPublicKey, string $contentEncoding): string
|
||||
{
|
||||
if ($contentEncoding === "aes128gcm")
|
||||
{
|
||||
return $salt
|
||||
. pack('N*', 4096)
|
||||
. pack('C*', Utils::safeStrlen($localPublicKey))
|
||||
. $localPublicKey;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @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");
|
||||
}
|
||||
}
|
||||
|
||||
private static function addNullPadding(string $data): string
|
||||
{
|
||||
return str_pad($data, 32, chr(0), STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private static function calculateAgreementKey(array $private_key, array $public_key): string
|
||||
{
|
||||
if (function_exists('openssl_pkey_derive'))
|
||||
{
|
||||
try
|
||||
{
|
||||
$publicPem = self::convertPublicKeyToPEM($public_key);
|
||||
$private_key = array_map([Base64Url::class, 'encode'], $private_key);
|
||||
$privatePem = self::convertPrivateKeyToPEM($private_key);
|
||||
|
||||
$result = openssl_pkey_derive($publicPem, $privatePem, 256);
|
||||
if ($result === false)
|
||||
{
|
||||
throw new \Exception('Unable to compute the agreement key');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
catch (\Throwable $throwable)
|
||||
{
|
||||
//Does nothing. Will fallback to the pure PHP function
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$curve = self::curve256();
|
||||
|
||||
$rec_x = self::convertBase64ToBigInteger($public_key['x']);
|
||||
$rec_y = self::convertBase64ToBigInteger($public_key['y']);
|
||||
$sen_d = self::convertBase64ToBigInteger($private_key['d']);
|
||||
$priv_key = PrivateKey::create($sen_d);
|
||||
$pub_key = $curve->getPublicKeyFrom($rec_x, $rec_y);
|
||||
|
||||
return hex2bin(str_pad($curve->mul($pub_key->getPoint(), $priv_key->getSecret())->getX()->toBase(16), 64, '0', STR_PAD_LEFT));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
private static function convertBase64ToBigInteger(string $value): BigInteger
|
||||
{
|
||||
try
|
||||
{
|
||||
$value = unpack('H*', Base64Url::decode($value));
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
$value = unpack('H*', $value);
|
||||
}
|
||||
|
||||
if ($value === false)
|
||||
{
|
||||
throw new \ErrorException('Unable to unpack hex value from string');
|
||||
}
|
||||
|
||||
return BigInteger::fromBase($value[1], 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
private static function convertBase64ToGMP(string $value): \GMP
|
||||
{
|
||||
$value = unpack('H*', Base64Url::decode($value));
|
||||
|
||||
if ($value === false)
|
||||
{
|
||||
throw new \ErrorException('Unable to unpack hex value from string');
|
||||
}
|
||||
|
||||
return gmp_init($value[1], 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
private static function createContext(string $clientPublicKey, string $serverPublicKey, string $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
|
||||
*
|
||||
* @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.');
|
||||
}
|
||||
|
||||
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);
|
||||
if (PHP_MAJOR_VERSION < 8)
|
||||
{
|
||||
openssl_pkey_free($keyResource);
|
||||
}
|
||||
|
||||
if (!$details)
|
||||
{
|
||||
throw new \RuntimeException('Unable to get the key details');
|
||||
}
|
||||
|
||||
return [
|
||||
'x' => self::addNullPadding($details['ec']['x']),
|
||||
'y' => self::addNullPadding($details['ec']['y']),
|
||||
'd' => self::addNullPadding($details['ec']['d']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException if the curve is not supported
|
||||
*/
|
||||
public static function convertPublicKeyToPEM(array $keyData): string
|
||||
{
|
||||
$der = pack(
|
||||
'H*',
|
||||
'3059' // SEQUENCE, length 89
|
||||
.'3013' // SEQUENCE, length 19
|
||||
.'0607' // OID, length 7
|
||||
.'2a8648ce3d0201' // 1.2.840.10045.2.1 = EC Public Key
|
||||
.'0608' // OID, length 8
|
||||
.'2a8648ce3d030107' // 1.2.840.10045.3.1.7 = P-256 Curve
|
||||
.'0342' // BIT STRING, length 66
|
||||
.'00' // prepend with NUL - pubkey will follow
|
||||
);
|
||||
$der .= "\04"
|
||||
. str_pad($keyData['x'], 32, "\0", STR_PAD_LEFT)
|
||||
. str_pad($keyData['y'], 32, "\0", STR_PAD_LEFT);
|
||||
$pem = '-----BEGIN PUBLIC KEY-----'.PHP_EOL;
|
||||
$pem .= chunk_split(base64_encode($der), 64, PHP_EOL);
|
||||
$pem .= '-----END PUBLIC KEY-----'.PHP_EOL;
|
||||
|
||||
return $pem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException if the curve is not supported
|
||||
*/
|
||||
public static function convertPrivateKeyToPEM(array $keyData): string
|
||||
{
|
||||
$d = unpack('H*', str_pad(Base64Url::decode($keyData['d']), 32, "\0", STR_PAD_LEFT));
|
||||
|
||||
if (!is_array($d) || !isset($d[1]))
|
||||
{
|
||||
throw new \InvalidArgumentException('Unable to get the private key');
|
||||
}
|
||||
|
||||
$der = pack(
|
||||
'H*',
|
||||
'3077' // SEQUENCE, length 87+length($d)=32
|
||||
. '020101' // INTEGER, 1
|
||||
. '0420' // OCTET STRING, length($d) = 32
|
||||
. $d[1]
|
||||
. 'a00a' // TAGGED OBJECT #0, length 10
|
||||
. '0608' // OID, length 8
|
||||
. '2a8648ce3d030107' // 1.3.132.0.34 = P-256 Curve
|
||||
. 'a144' // TAGGED OBJECT #1, length 68
|
||||
. '0342' // BIT STRING, length 66
|
||||
. '00' // prepend with NUL - pubkey will follow
|
||||
);
|
||||
$der .= "\04"
|
||||
. str_pad(Base64Url::decode($keyData['x']), 32, "\0", STR_PAD_LEFT)
|
||||
. str_pad(Base64Url::decode($keyData['y']), 32, "\0", STR_PAD_LEFT);
|
||||
$pem = '-----BEGIN EC PRIVATE KEY-----'.PHP_EOL;
|
||||
$pem .= chunk_split(base64_encode($der), 64, PHP_EOL);
|
||||
$pem .= '-----END EC PRIVATE KEY-----'.PHP_EOL;
|
||||
|
||||
return $pem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an NIST P-256 curve.
|
||||
*/
|
||||
private static function curve256(): Curve
|
||||
{
|
||||
$p = BigInteger::fromBase('ffffffff00000001000000000000000000000000ffffffffffffffffffffffff', 16);
|
||||
$a = BigInteger::fromBase('ffffffff00000001000000000000000000000000fffffffffffffffffffffffc', 16);
|
||||
$b = BigInteger::fromBase('5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b', 16);
|
||||
$x = BigInteger::fromBase('6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296', 16);
|
||||
$y = BigInteger::fromBase('4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5', 16);
|
||||
$n = BigInteger::fromBase('ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551', 16);
|
||||
$generator = Point::create($x, $y, $n);
|
||||
|
||||
return new Curve(256, $p, $a, $b, $generator);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* Akeeba WebPush
|
||||
*
|
||||
* An abstraction layer for easier implementation of WebPush in Joomla components.
|
||||
*
|
||||
* @copyright (c) 2022 Akeeba Ltd
|
||||
* @license GNU GPL v3 or later; see LICENSE.txt
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace Akeeba\WebPush\WebPush;
|
||||
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* This class is a derivative work based on the WebPush library by Louis Lagrange. It has been modified to only use
|
||||
* dependencies shipped with Joomla itself and must not be confused with the original work.
|
||||
*
|
||||
* You can find the original code at https://github.com/web-push-libs
|
||||
*
|
||||
* The original code came with the following copyright notice:
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* 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-LAGRANGE.txt
|
||||
* file that was distributed with this source code.
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* @author Igor Timoshenkov [it@campoint.net]
|
||||
* @started: 03.09.2018 9:21
|
||||
*
|
||||
* Standardized response from sending a message
|
||||
*/
|
||||
class MessageSentReport implements \JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $reason;
|
||||
|
||||
/**
|
||||
* @var RequestInterface
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* @var ResponseInterface | null
|
||||
*/
|
||||
protected $response;
|
||||
|
||||
/**
|
||||
* @var boolean
|
||||
*/
|
||||
protected $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;
|
||||
}
|
||||
|
||||
public function getEndpoint(): string
|
||||
{
|
||||
return $this->request->getUri()->__toString();
|
||||
}
|
||||
|
||||
public function getReason(): string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function setReason(string $reason): MessageSentReport
|
||||
{
|
||||
$this->reason = $reason;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRequest(): RequestInterface
|
||||
{
|
||||
return $this->request;
|
||||
}
|
||||
|
||||
public function setRequest(RequestInterface $request): MessageSentReport
|
||||
{
|
||||
$this->request = $request;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRequestPayload(): string
|
||||
{
|
||||
return $this->request->getBody()->getContents();
|
||||
}
|
||||
|
||||
public function getResponse(): ?ResponseInterface
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
|
||||
public function setResponse(ResponseInterface $response): MessageSentReport
|
||||
{
|
||||
$this->response = $response;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResponseContent(): ?string
|
||||
{
|
||||
if (!$this->response)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->response->getBody()->getContents();
|
||||
}
|
||||
|
||||
public function isSubscriptionExpired(): bool
|
||||
{
|
||||
if (!$this->response)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return \in_array($this->response->getStatusCode(), [404, 410], true);
|
||||
}
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->success;
|
||||
}
|
||||
|
||||
public function setSuccess(bool $success): MessageSentReport
|
||||
{
|
||||
$this->success = $success;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'success' => $this->isSuccess(),
|
||||
'expired' => $this->isSubscriptionExpired(),
|
||||
'reason' => $this->reason,
|
||||
'endpoint' => $this->getEndpoint(),
|
||||
'payload' => $this->request->getBody()->getContents(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/**
|
||||
* Akeeba WebPush
|
||||
*
|
||||
* An abstraction layer for easier implementation of WebPush in Joomla components.
|
||||
*
|
||||
* @copyright (c) 2022 Akeeba Ltd
|
||||
* @license GNU GPL v3 or later; see LICENSE.txt
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Akeeba\WebPush\WebPush;
|
||||
|
||||
use function count;
|
||||
|
||||
/**
|
||||
* This class is a derivative work based on the WebPush library by Louis Lagrange. It has been modified to only use
|
||||
* dependencies shipped with Joomla itself and must not be confused with the original work.
|
||||
*
|
||||
* You can find the original code at https://github.com/web-push-libs
|
||||
*
|
||||
* The original code came with the following copyright notice:
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* 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-LAGRANGE.txt
|
||||
* file that was distributed with this source code.
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*/
|
||||
class Notification
|
||||
{
|
||||
/** @var array Auth details : VAPID */
|
||||
private $auth;
|
||||
|
||||
/** @var array Options : TTL, urgency, topic */
|
||||
private $options;
|
||||
|
||||
/** @var null|string */
|
||||
private $payload;
|
||||
|
||||
/** @var SubscriptionInterface */
|
||||
private $subscription;
|
||||
|
||||
public function __construct(SubscriptionInterface $subscription, ?string $payload, array $options, array $auth)
|
||||
{
|
||||
$this->subscription = $subscription;
|
||||
$this->payload = $payload;
|
||||
$this->options = $options;
|
||||
$this->auth = $auth;
|
||||
}
|
||||
|
||||
public function getAuth(array $defaultAuth): array
|
||||
{
|
||||
return count($this->auth) > 0 ? $this->auth : $defaultAuth;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getPayload(): ?string
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
public function getSubscription(): SubscriptionInterface
|
||||
{
|
||||
return $this->subscription;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
/**
|
||||
* Akeeba WebPush
|
||||
*
|
||||
* An abstraction layer for easier implementation of WebPush in Joomla components.
|
||||
*
|
||||
* @copyright (c) 2022 Akeeba Ltd
|
||||
* @license GNU GPL v3 or later; see LICENSE.txt
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Akeeba\WebPush\WebPush;
|
||||
|
||||
/**
|
||||
* This class is a derivative work based on the WebPush library by Louis Lagrange. It has been modified to only use
|
||||
* dependencies shipped with Joomla itself and must not be confused with the original work.
|
||||
*
|
||||
* You can find the original code at https://github.com/web-push-libs
|
||||
*
|
||||
* The original code came with the following copyright notice:
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* 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-LAGRANGE.txt
|
||||
* file that was distributed with this source code.
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*/
|
||||
class Subscription implements SubscriptionInterface
|
||||
{
|
||||
/** @var null|string */
|
||||
private $authToken;
|
||||
|
||||
/** @var null|string */
|
||||
private $contentEncoding;
|
||||
|
||||
/** @var string */
|
||||
private $endpoint;
|
||||
|
||||
/** @var null|string */
|
||||
private $publicKey;
|
||||
|
||||
/**
|
||||
* @param string|null $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";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $associativeArray (with keys endpoint, publicKey, authToken, contentEncoding)
|
||||
*
|
||||
* @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 getAuthToken(): ?string
|
||||
{
|
||||
return $this->authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getContentEncoding(): ?string
|
||||
{
|
||||
return $this->contentEncoding;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getEndpoint(): string
|
||||
{
|
||||
return $this->endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getPublicKey(): ?string
|
||||
{
|
||||
return $this->publicKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
/**
|
||||
* Akeeba WebPush
|
||||
*
|
||||
* An abstraction layer for easier implementation of WebPush in Joomla components.
|
||||
*
|
||||
* @copyright (c) 2022 Akeeba Ltd
|
||||
* @license GNU GPL v3 or later; see LICENSE.txt
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Akeeba\WebPush\WebPush;
|
||||
|
||||
/**
|
||||
* This class is a derivative work based on the WebPush library by Louis Lagrange. It has been modified to only use
|
||||
* dependencies shipped with Joomla itself and must not be confused with the original work.
|
||||
*
|
||||
* You can find the original code at https://github.com/web-push-libs
|
||||
*
|
||||
* The original code came with the following copyright notice:
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* 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-LAGRANGE.txt
|
||||
* file that was distributed with this source code.
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* @author Sergii Bondarenko <sb@firstvector.org>
|
||||
*/
|
||||
interface SubscriptionInterface
|
||||
{
|
||||
public function getAuthToken(): ?string;
|
||||
|
||||
public function getContentEncoding(): ?string;
|
||||
|
||||
public function getEndpoint(): string;
|
||||
|
||||
public function getPublicKey(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* Akeeba WebPush
|
||||
*
|
||||
* An abstraction layer for easier implementation of WebPush in Joomla components.
|
||||
*
|
||||
* @copyright (c) 2022 Akeeba Ltd
|
||||
* @license GNU GPL v3 or later; see LICENSE.txt
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Akeeba\WebPush\WebPush;
|
||||
|
||||
use function mb_strlen;
|
||||
use function mb_substr;
|
||||
|
||||
/**
|
||||
* This class is a derivative work based on the WebPush library by Louis Lagrange. It has been modified to only use
|
||||
* dependencies shipped with Joomla itself and must not be confused with the original work.
|
||||
*
|
||||
* You can find the original code at https://github.com/web-push-libs
|
||||
*
|
||||
* The original code came with the following copyright notice:
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* 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-LAGRANGE.txt
|
||||
* file that was distributed with this source code.
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*/
|
||||
class Utils
|
||||
{
|
||||
public static function safeStrlen(string $value): int
|
||||
{
|
||||
return mb_strlen($value, '8bit');
|
||||
}
|
||||
|
||||
public static function serializePublicKeyFromData(array $data): string
|
||||
{
|
||||
$hexString = '04';
|
||||
$hexString .= str_pad(bin2hex($data['x']), 64, '0', STR_PAD_LEFT);
|
||||
$hexString .= str_pad(bin2hex($data['y']), 64, '0', STR_PAD_LEFT);
|
||||
|
||||
return $hexString;
|
||||
}
|
||||
|
||||
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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
/**
|
||||
* Akeeba WebPush
|
||||
*
|
||||
* An abstraction layer for easier implementation of WebPush in Joomla components.
|
||||
*
|
||||
* @copyright (c) 2022 Akeeba Ltd
|
||||
* @license GNU GPL v3 or later; see LICENSE.txt
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Akeeba\WebPush\WebPush;
|
||||
|
||||
use Base64Url\Base64Url;
|
||||
use DateTimeImmutable;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Ecdsa\Sha256;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
|
||||
/**
|
||||
* This class is a derivative work based on the WebPush library by Louis Lagrange. It has been modified to only use
|
||||
* dependencies shipped with Joomla itself and must not be confused with the original work.
|
||||
*
|
||||
* You can find the original code at https://github.com/web-push-libs
|
||||
*
|
||||
* The original code came with the following copyright notice:
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* 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-LAGRANGE.txt
|
||||
* file that was distributed with this source code.
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*/
|
||||
class VAPID
|
||||
{
|
||||
private const PUBLIC_KEY_LENGTH = 65;
|
||||
|
||||
private const PRIVATE_KEY_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public static function createVapidKeys(): array
|
||||
{
|
||||
$keyData = self::createECKeyUsingOpenSSL();
|
||||
|
||||
$binaryPublicKey = hex2bin(Utils::serializePublicKeyFromData($keyData));
|
||||
|
||||
if (!$binaryPublicKey)
|
||||
{
|
||||
throw new \ErrorException('Failed to convert VAPID public key from hexadecimal to binary');
|
||||
}
|
||||
|
||||
$binaryPrivateKey = hex2bin(str_pad(bin2hex($keyData['d']), 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),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 $signingKey The decoded VAPID private key
|
||||
* @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 $signingKey, string $contentEncoding, ?int $expiration = null)
|
||||
{
|
||||
if (!class_exists(\Lcobucci\JWT\Signer\OpenSSL::class, false))
|
||||
{
|
||||
require_once __DIR__ . '/../Workarounds/OpenSSL.php';
|
||||
}
|
||||
|
||||
// Get the full key data from the public and private key
|
||||
$keyData = Utils::unserializePublicKey($publicKey);
|
||||
$keyData[] = $signingKey;
|
||||
$keyData = array_combine(['x', 'y', 'd'], $keyData);
|
||||
$keyData = array_map([Base64Url::class, 'encode'], $keyData);
|
||||
|
||||
// Get an in-memory key (see https://github.com/lcobucci/jwt/blob/3.4.x/docs/configuration.md)
|
||||
$privateKeyPem = Encryption::convertPrivateKeyToPEM($keyData);
|
||||
$publicKeyPem = Encryption::convertPublicKeyToPEM($keyData);
|
||||
$signingKey = InMemory::plainText($privateKeyPem);
|
||||
$verificationKey = InMemory::plainText($publicKeyPem);
|
||||
|
||||
// Calculate expiration date and time
|
||||
$expirationLimit = time() + 43200; // equal margin of error between 0 and 24h
|
||||
if (null === $expiration || $expiration > $expirationLimit)
|
||||
{
|
||||
$expiration = $expirationLimit;
|
||||
}
|
||||
// Get current data and time
|
||||
// Get the JWT
|
||||
$configuration = Configuration::forAsymmetricSigner(new Sha256(), $signingKey, $verificationKey);
|
||||
$token = $configuration->builder()
|
||||
->setAudience($audience)
|
||||
->expiresAt(new DateTimeImmutable('@' . $expiration))
|
||||
->setSubject($subject)
|
||||
->issuedAt(new DateTimeImmutable())
|
||||
->getToken($configuration->signer(), $configuration->signingKey());
|
||||
$jwt = $token->toString();
|
||||
|
||||
// Get the authorisation headers
|
||||
$encodedPublicKey = Base64Url::encode($publicKey);
|
||||
|
||||
if ($contentEncoding === "aesgcm")
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'WebPush ' . $jwt,
|
||||
'Crypto-Key' => 'p256ecdsa=' . $encodedPublicKey,
|
||||
];
|
||||
}
|
||||
|
||||
if ($contentEncoding === 'aes128gcm')
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'vapid t=' . $jwt . ', k=' . $encodedPublicKey,
|
||||
];
|
||||
}
|
||||
|
||||
throw new \ErrorException('This content encoding is not supported');
|
||||
}
|
||||
|
||||
/**
|
||||
* @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['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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new elliptic curve key using the P-256 curve and OpenSSL
|
||||
*
|
||||
* @throws \RuntimeException if the extension OpenSSL is not available
|
||||
* @throws \RuntimeException if the key cannot be created
|
||||
*/
|
||||
private static function createECKeyUsingOpenSSL(): array
|
||||
{
|
||||
if (!extension_loaded('openssl'))
|
||||
{
|
||||
throw new \RuntimeException('Please install the OpenSSL extension');
|
||||
}
|
||||
$key = openssl_pkey_new([
|
||||
'curve_name' => 'prime256v1',
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
]);
|
||||
|
||||
if ($key === false)
|
||||
{
|
||||
throw new \RuntimeException('Unable to create the key');
|
||||
}
|
||||
|
||||
$result = openssl_pkey_export($key, $out);
|
||||
|
||||
if ($result === false)
|
||||
{
|
||||
throw new \RuntimeException('Unable to create the key');
|
||||
}
|
||||
|
||||
$res = openssl_pkey_get_private($out);
|
||||
|
||||
if ($res === false)
|
||||
{
|
||||
throw new \RuntimeException('Unable to create the key');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($res);
|
||||
|
||||
if ($details === false)
|
||||
{
|
||||
throw new \InvalidArgumentException('Unable to get the key details');
|
||||
}
|
||||
|
||||
return [
|
||||
'd' => $details['ec']['d'],
|
||||
'x' => $details['ec']['x'],
|
||||
'y' => $details['ec']['y'],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
/**
|
||||
* Akeeba WebPush
|
||||
*
|
||||
* An abstraction layer for easier implementation of WebPush in Joomla components.
|
||||
*
|
||||
* @copyright (c) 2022 Akeeba Ltd
|
||||
* @license GNU GPL v3 or later; see LICENSE.txt
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Akeeba\WebPush\WebPush;
|
||||
|
||||
use Base64Url\Base64Url;
|
||||
use Joomla\CMS\Http\Http as HttpClient;
|
||||
use Joomla\CMS\Http\HttpFactory;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Laminas\Diactoros\Request;
|
||||
use Laminas\Diactoros\StreamFactory;
|
||||
use function count;
|
||||
|
||||
/**
|
||||
* This class is a derivative work based on the WebPush library by Louis Lagrange. It has been modified to only use
|
||||
* dependencies shipped with Joomla itself and must not be confused with the original work.
|
||||
*
|
||||
* You can find the original code at https://github.com/web-push-libs
|
||||
*
|
||||
* The original code came with the following copyright notice:
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*
|
||||
* 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-LAGRANGE.txt
|
||||
* file that was distributed with this source code.
|
||||
*
|
||||
* =====================================================================================================================
|
||||
*/
|
||||
class WebPush
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $auth;
|
||||
|
||||
/**
|
||||
* @var int Automatic padding of payloads, if disabled, trade security for bandwidth
|
||||
*/
|
||||
protected $automaticPadding = Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH;
|
||||
|
||||
/**
|
||||
* @var HttpClient
|
||||
*/
|
||||
protected $client;
|
||||
|
||||
/**
|
||||
* @var array Default options : TTL, urgency, topic, batchSize
|
||||
*/
|
||||
protected $defaultOptions;
|
||||
|
||||
/**
|
||||
* @var null|array Array of array of Notifications
|
||||
*/
|
||||
protected $notifications;
|
||||
|
||||
/**
|
||||
* @var bool Reuse VAPID headers in the same flush session to improve performance
|
||||
*/
|
||||
protected $reuseVAPIDHeaders = false;
|
||||
|
||||
/**
|
||||
* @var array Dictionary for VAPID headers cache
|
||||
*/
|
||||
protected $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
|
||||
*
|
||||
* @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.',
|
||||
'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.',
|
||||
];
|
||||
$phpVersion = phpversion();
|
||||
if ($phpVersion && version_compare($phpVersion, '7.3.0', '<'))
|
||||
{
|
||||
$extensions['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.';
|
||||
}
|
||||
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 = HttpFactory::getHttp($clientOptions);
|
||||
}
|
||||
|
||||
public function countPendingNotifications(): int
|
||||
{
|
||||
return null !== $this->notifications ? count($this->notifications) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (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);
|
||||
|
||||
foreach ($requests as $request)
|
||||
{
|
||||
try
|
||||
{
|
||||
// So, this SHOULD work, but it doesn't because of a Joomla Framework bug. HARD MODE ENGAGED.
|
||||
//$response = $this->client->sendRequest($request);
|
||||
|
||||
$httpMethod = strtolower($request->getMethod());
|
||||
|
||||
$headers = array_map(
|
||||
function ($values)
|
||||
{
|
||||
if (!is_array($values))
|
||||
{
|
||||
return $values;
|
||||
}
|
||||
|
||||
return implode(' ', $values);
|
||||
},
|
||||
$request->getHeaders()
|
||||
);
|
||||
|
||||
$timeout = $this->client->getOption('timeout', 10);
|
||||
|
||||
switch ($httpMethod)
|
||||
{
|
||||
case 'options':
|
||||
case 'head':
|
||||
case 'get':
|
||||
case 'trace':
|
||||
default:
|
||||
$response = $this->client->{$httpMethod}(new Uri($request->getUri()), $headers, $timeout);
|
||||
break;
|
||||
|
||||
case 'post':
|
||||
case 'put':
|
||||
case 'delete':
|
||||
case 'patch':
|
||||
$response = $this->client->{$httpMethod}(new Uri($request->getUri()), $request->getBody()->getContents(), $headers, $timeout);
|
||||
break;
|
||||
}
|
||||
|
||||
$success = $response->getStatusCode() >= 200 && $response->getStatusCode() < 400;
|
||||
$reason = $success ? 'OK' : (strip_tags($response->body) ?: $response->getReasonPhrase());
|
||||
|
||||
yield new MessageSentReport($request, $response, $success, $reason);
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
yield new MessageSentReport($request, $response, false, $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->reuseVAPIDHeaders)
|
||||
{
|
||||
$this->vapidHeaders = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getAutomaticPadding()
|
||||
{
|
||||
return $this->automaticPadding;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|bool $automaticPadding Max padding length
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
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'] = $defaultOptions['TTL'] ?? 2419200;
|
||||
$this->defaultOptions['urgency'] = $defaultOptions['urgency'] ?? null;
|
||||
$this->defaultOptions['topic'] = $defaultOptions['topic'] ?? null;
|
||||
$this->defaultOptions['batchSize'] = $defaultOptions['batchSize'] ?? 1000;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function getReuseVAPIDHeaders()
|
||||
{
|
||||
return $this->reuseVAPIDHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse VAPID headers in the same flush session to improve performance
|
||||
*
|
||||
* @return WebPush
|
||||
*/
|
||||
public function setReuseVAPIDHeaders(bool $enabled)
|
||||
{
|
||||
$this->reuseVAPIDHeaders = $enabled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAutomaticPadding(): bool
|
||||
{
|
||||
return $this->automaticPadding !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a notification. Will be sent when flush() is called.
|
||||
*
|
||||
* @param string|null $payload If you want to send an array or object, json_encode it
|
||||
* @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
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function queueNotification(SubscriptionInterface $subscription, ?string $payload = null, array $options = [], array $auth = []): void
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $payload If you want to send an array or object, json_encode it
|
||||
* @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
|
||||
*
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
public function sendOneNotification(SubscriptionInterface $subscription, ?string $payload = null, array $options = [], array $auth = []): MessageSentReport
|
||||
{
|
||||
$this->queueNotification($subscription, $payload, $options, $auth);
|
||||
|
||||
return $this->flush()->current();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @throws \ErrorException
|
||||
*/
|
||||
protected 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Request[]
|
||||
* @throws \ErrorException
|
||||
*
|
||||
*/
|
||||
protected function prepare(array $notifications): array
|
||||
{
|
||||
$requests = [];
|
||||
foreach ($notifications as $notification)
|
||||
{
|
||||
\assert($notification instanceof 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'] = (string) 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 (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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$streamFactory = new StreamFactory();
|
||||
|
||||
$requests[] = new Request($endpoint, 'POST', $streamFactory->createStream($content), $headers);
|
||||
}
|
||||
|
||||
return $requests;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user