Created
December 21, 2023 03:46
-
-
Save md-riaz/52861c47f7387babe50d62cbce6b0458 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
// enable errors | |
ini_set('display_errors', 1); | |
ini_set('display_startup_errors', 1); | |
error_reporting(E_ALL); | |
trait PNServerHelper | |
{ | |
/** | |
* Get classname without namespace. | |
* @param mixed $o | |
* @return string | |
*/ | |
public static function className($o): string | |
{ | |
$strName = ''; | |
if (is_object($o)) { | |
$path = explode('\\', get_class($o)); | |
$strName = array_pop($path); | |
} | |
return $strName; | |
} | |
/** | |
* Encode data to Base64URL. | |
* @param string $data | |
* @return string encoded string | |
*/ | |
public static function encodeBase64URL(string $data): string | |
{ | |
// Convert Base64 to Base64URL by replacing ?+? with ?-? and ?/? with ?_? | |
$url = strtr(base64_encode($data), '+/', '-_'); | |
// Remove padding character from the end of line and return the Base64URL result | |
return rtrim($url, '='); | |
} | |
/** | |
* Decode data from Base64URL. | |
* If the strict parameter is set to TRUE then the function will return false | |
* if the input contains character from outside the base64 alphabet. Otherwise | |
* invalid characters will be silently discarded. | |
* @param string $data | |
* @param boolean $strict | |
* @return string | |
*/ | |
public static function decodeBase64URL(string $data, bool $strict = false): string | |
{ | |
// Convert Base64URL to Base64 by replacing ?-? with ?+? and ?_? with ?/? | |
$b64 = strtr($data, '-_', '+/'); | |
// Decode Base64 string and return the original data | |
$strDecoded = base64_decode($b64, $strict); | |
return $strDecoded !== false ? $strDecoded : 'error'; | |
} | |
public static function getP256PEM(string $strPublicKey, string $strPrivateKey): string | |
{ | |
$der = self::p256PrivateKey($strPrivateKey); | |
$der .= $strPublicKey; | |
$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; | |
} | |
private static function p256PrivateKey(string $strPrivateKey): string | |
{ | |
$aUP = \unpack('H*', str_pad($strPrivateKey, 32, "\0", STR_PAD_LEFT)); | |
$key = ''; | |
if ($aUP !== false) { | |
$key = $aUP[1]; | |
} | |
return pack( | |
'H*', | |
'3077' // SEQUENCE, length 87+length($d)=32 | |
. '020101' // INTEGER, 1 | |
. '0420' // OCTET STRING, length($d) = 32 | |
. $key | |
. '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 | |
); | |
} | |
/** | |
* @param string $der | |
* @return string|false | |
*/ | |
public static function signatureFromDER(string $der) | |
{ | |
$sig = false; | |
$R = false; | |
$S = false; | |
$aUP = \unpack('H*', $der); | |
$hex = ''; | |
if ($aUP !== false) { | |
$hex = $aUP[1]; | |
} | |
if ('30' === \mb_substr($hex, 0, 2, '8bit')) { | |
// SEQUENCE | |
if ('81' === \mb_substr($hex, 2, 2, '8bit')) { | |
// LENGTH > 128 | |
$hex = \mb_substr($hex, 6, null, '8bit'); | |
} else { | |
$hex = \mb_substr($hex, 4, null, '8bit'); | |
} | |
if ('02' === \mb_substr($hex, 0, 2, '8bit')) { | |
// INTEGER | |
$Rl = (int)\hexdec(\mb_substr($hex, 2, 2, '8bit')); | |
$R = self::retrievePosInt(\mb_substr($hex, 4, $Rl * 2, '8bit')); | |
$R = \str_pad($R, 64, '0', STR_PAD_LEFT); | |
$hex = \mb_substr($hex, 4 + $Rl * 2, null, '8bit'); | |
if ('02' === \mb_substr($hex, 0, 2, '8bit')) { | |
// INTEGER | |
$Sl = (int)\hexdec(\mb_substr($hex, 2, 2, '8bit')); | |
$S = self::retrievePosInt(\mb_substr($hex, 4, $Sl * 2, '8bit')); | |
$S = \str_pad($S, 64, '0', STR_PAD_LEFT); | |
} | |
} | |
} | |
if ($R !== false && $S !== false) { | |
$sig = \pack('H*', $R . $S); | |
} | |
return $sig; | |
} | |
private static function retrievePosInt(string $data): string | |
{ | |
while ('00' === \mb_substr($data, 0, 2, '8bit') && \mb_substr($data, 2, 2, '8bit') > '7f') { | |
$data = \mb_substr($data, 2, null, '8bit'); | |
} | |
return $data; | |
} | |
public static function getXYFromPublicKey(string $strKey, string &$x, string &$y): bool | |
{ | |
$bSucceeded = false; | |
$hexData = bin2hex($strKey); | |
if (mb_substr($hexData, 0, 2, '8bit') === '04') { | |
$hexData = mb_substr($hexData, 2, null, '8bit'); | |
$dataLength = mb_strlen($hexData, '8bit'); | |
$x = hex2bin(mb_substr($hexData, 0, $dataLength / 2, '8bit')); | |
$y = hex2bin(mb_substr($hexData, $dataLength / 2, null, '8bit')); | |
} | |
return $bSucceeded; | |
} | |
} | |
class PNVapid | |
{ | |
use PNServerHelper; | |
/** lenght of public key (Base64URL - decoded) */ | |
const PUBLIC_KEY_LENGTH = 65; | |
/** lenght of private key (Base64URL - decoded) */ | |
const PRIVATE_KEY_LENGTH = 32; | |
const ERR_EMPTY_ARGUMENT = 'Empty Argument!'; | |
const ERR_INVALID_PUBLIC_KEY_LENGTH = 'Invalid public key length!'; | |
const ERR_INVALID_PRIVATE_KEY_LENGTH = 'Invalid private key length!'; | |
const ERR_NO_COMPRESSED_KEY_SUPPORTED = 'Invalid public key: only uncompressed keys are supported!'; | |
/** @var string VAPID subject (email or uri) */ | |
protected $strSubject = ''; | |
/** @var string public key */ | |
protected $strPublicKey = ''; | |
/** @var string private key */ | |
protected $strPrivateKey = ''; | |
/** @var string last error msg */ | |
protected $strError = ''; | |
/** | |
* @param string $strSubject usually 'mailto:mymail@mydomain.de' | |
* @param string $strPublicKey | |
* @param string $strPrivateKey | |
*/ | |
public function __construct(string $strSubject, string $strPublicKey, string $strPrivateKey) | |
{ | |
$this->strSubject = $strSubject; | |
$this->strPublicKey = self::decodeBase64URL($strPublicKey); | |
$this->strPrivateKey = self::decodeBase64URL($strPrivateKey); | |
} | |
/** | |
* Check for valid VAPID. | |
* - subject, public key and private key must be set <br> | |
* - decoded public key must be 65 bytes long <br> | |
* - no compresed public key supported <br> | |
* - decoded private key must be 32 bytes long <br> | |
* @return bool | |
*/ | |
public function isValid(): bool | |
{ | |
if (strlen($this->strSubject) == 0 || | |
strlen($this->strPublicKey) == 0 || | |
strlen($this->strPrivateKey) == 0) { | |
$this->strError = self::ERR_EMPTY_ARGUMENT; | |
return false; | |
} | |
if (mb_strlen($this->strPublicKey, '8bit') !== self::PUBLIC_KEY_LENGTH) { | |
$this->strError = self::ERR_INVALID_PUBLIC_KEY_LENGTH; | |
return false; | |
} | |
$hexPublicKey = bin2hex($this->strPublicKey); | |
if (mb_substr($hexPublicKey, 0, 2, '8bit') !== '04') { | |
$this->strError = self::ERR_NO_COMPRESSED_KEY_SUPPORTED; | |
return false; | |
} | |
if (mb_strlen($this->strPrivateKey, '8bit') !== self::PRIVATE_KEY_LENGTH) { | |
$this->strError = self::ERR_INVALID_PRIVATE_KEY_LENGTH; | |
return false; | |
} | |
return true; | |
} | |
/** | |
* Create header for endpoint using current timestamp. | |
* @param string $strEndpoint | |
* @return array<string,string>|false headers if succeeded, false on error | |
*/ | |
public function getHeaders(string $strEndpoint) | |
{ | |
$aHeaders = false; | |
// info | |
$aJwtInfo = array("typ" => "JWT", "alg" => "ES256"); | |
$jsonJwtInfo = json_encode($aJwtInfo); | |
$strJwtInfo = 'invalid'; | |
if ($jsonJwtInfo !== false) { | |
$strJwtInfo = self::encodeBase64URL($jsonJwtInfo); | |
} | |
// data | |
// - origin from endpoint | |
// - timeout 12h from now | |
// - subject (e-mail or URL to invoker of VAPID-keys) | |
// TODO: change param to $strEndPointOrigin to eliminate dependency to PNSubscription! | |
$aJwtData = array( | |
'aud' => PNSubscription::getOrigin($strEndpoint), | |
'exp' => time() + 43200, | |
'sub' => $this->strSubject | |
); | |
$jsonJwtData = json_encode($aJwtData); | |
$strJwtData = 'invalid'; | |
if ($jsonJwtData !== false) { | |
$strJwtData = self::encodeBase64URL($jsonJwtData); | |
} | |
// signature | |
// ECDSA encrypting "JwtInfo.JwtData" using the P-256 curve and the SHA-256 hash algorithm | |
$strData = $strJwtInfo . '.' . $strJwtData; | |
$pem = self::getP256PEM($this->strPublicKey, $this->strPrivateKey); | |
$this->strError = 'Error creating signature!'; | |
$strSignature = ''; | |
if (\openssl_sign($strData, $strSignature, $pem, OPENSSL_ALGO_SHA256)) { | |
if (($sig = self::signatureFromDER($strSignature)) !== false) { | |
$this->strError = ''; | |
$strSignature = self::encodeBase64URL($sig); | |
$aHeaders = [ | |
'Authorization' => 'WebPush ' . $strJwtInfo . '.' . $strJwtData . '.' . $strSignature, | |
'Crypto-Key' => 'p256ecdsa=' . self::encodeBase64URL($this->strPublicKey), | |
]; | |
} | |
} | |
return $aHeaders; | |
} | |
/** | |
* @return string last error | |
*/ | |
public function getError(): string | |
{ | |
return $this->strError; | |
} | |
} | |
class Math | |
{ | |
public static function cmp(\GMP $first, \GMP $other): int | |
{ | |
return \gmp_cmp($first, $other); | |
} | |
public static function equals(\GMP $first, \GMP $other): bool | |
{ | |
return 0 === \gmp_cmp($first, $other); | |
} | |
public static function add(\GMP $augend, \GMP $addend): \GMP | |
{ | |
return \gmp_add($augend, $addend); | |
} | |
public static function pow(\GMP $base, int $exponent): \GMP | |
{ | |
return \gmp_pow($base, $exponent); | |
} | |
public static function bitwiseAnd(\GMP $first, \GMP $other): \GMP | |
{ | |
return \gmp_and($first, $other); | |
} | |
public static function bitwiseXor(\GMP $first, \GMP $other): \GMP | |
{ | |
return \gmp_xor($first, $other); | |
} | |
public static function toString(\GMP $value): string | |
{ | |
return \gmp_strval($value); | |
} | |
/** | |
* @param int|string $number | |
* @param int $from | |
* @param int $to | |
* @return string | |
*/ | |
public static function baseConvert($number, int $from, int $to): string | |
{ | |
return \gmp_strval(\gmp_init($number, $from), $to); | |
} | |
public static function rightShift(\GMP $number, int $positions): \GMP | |
{ | |
// when using \gmp_div, phpStan says: Method SKien\PNServer\Utils\Math::rightShift() should return GMP but returns resource. ? | |
return \gmp_div_q($number, \gmp_pow(\gmp_init(2, 10), $positions)); | |
} | |
public static function modSub(\GMP $minuend, \GMP $subtrahend, \GMP $modulus): \GMP | |
{ | |
return self::mod(self::sub($minuend, $subtrahend), $modulus); | |
} | |
public static function mod(\GMP $number, \GMP $modulus): \GMP | |
{ | |
return \gmp_mod($number, $modulus); | |
} | |
public static function sub(\GMP $minuend, \GMP $subtrahend): \GMP | |
{ | |
return \gmp_sub($minuend, $subtrahend); | |
} | |
public static function modMul(\GMP $multiplier, \GMP $muliplicand, \GMP $modulus): \GMP | |
{ | |
return self::mod(self::mul($multiplier, $muliplicand), $modulus); | |
} | |
public static function mul(\GMP $multiplier, \GMP $multiplicand): \GMP | |
{ | |
return \gmp_mul($multiplier, $multiplicand); | |
} | |
public static function modDiv(\GMP $dividend, \GMP $divisor, \GMP $modulus): \GMP | |
{ | |
$moddiv = gmp_init(0); | |
$invmod = self::inverseMod($divisor, $modulus); | |
if ($invmod !== false) { | |
$moddiv = self::mul($dividend, $invmod); | |
} | |
return $moddiv; | |
} | |
/** | |
* @param \GMP $a | |
* @param \GMP $m | |
* @return \GMP|false | |
*/ | |
public static function inverseMod(\GMP $a, \GMP $m) | |
{ | |
return \gmp_invert($a, $m); | |
} | |
} | |
class Point | |
{ | |
/** @var \GMP */ | |
private $x; | |
/** @var \GMP */ | |
private $y; | |
/** @var \GMP */ | |
private $order; | |
/** @var bool */ | |
private $infinity = false; | |
/** | |
* Initialize a new instance. | |
* @throws \RuntimeException when either the curve does not contain the given coordinates or | |
* when order is not null and P(x, y) * order is not equal to infinity | |
*/ | |
private function __construct(\GMP $x, \GMP $y, \GMP $order, bool $infinity = false) | |
{ | |
$this->x = $x; | |
$this->y = $y; | |
$this->order = $order; | |
$this->infinity = $infinity; | |
} | |
/** | |
* @return Point | |
*/ | |
public static function create(\GMP $x, \GMP $y, \GMP $order = null): Point | |
{ | |
return new self($x, $y, null === $order ? \gmp_init(0, 10) : $order); | |
} | |
/** | |
* @return Point | |
*/ | |
public static function infinity(): Point | |
{ | |
$zero = \gmp_init(0, 10); | |
return new self($zero, $zero, $zero, true); | |
} | |
/** | |
* @param Point $a | |
* @param Point $b | |
* @param int $cond | |
*/ | |
public static function cswap(Point $a, Point $b, int $cond): void | |
{ | |
self::cswapGMP($a->x, $b->x, $cond); | |
self::cswapGMP($a->y, $b->y, $cond); | |
self::cswapGMP($a->order, $b->order, $cond); | |
self::cswapBoolean($a->infinity, $b->infinity, $cond); | |
} | |
private static function cswapGMP(\GMP &$sa, \GMP &$sb, int $cond): void | |
{ | |
$size = \max(\mb_strlen(\gmp_strval($sa, 2), '8bit'), \mb_strlen(\gmp_strval($sb, 2), '8bit')); | |
$mask = (string)(1 - (int)($cond)); | |
$mask = \str_pad('', $size, $mask, STR_PAD_LEFT); | |
$mask = \gmp_init($mask, 2); | |
$taA = Math::bitwiseAnd($sa, $mask); | |
$taB = Math::bitwiseAnd($sb, $mask); | |
$sa = Math::bitwiseXor(Math::bitwiseXor($sa, $sb), $taB); | |
$sb = Math::bitwiseXor(Math::bitwiseXor($sa, $sb), $taA); | |
$sa = Math::bitwiseXor(Math::bitwiseXor($sa, $sb), $taB); | |
} | |
private static function cswapBoolean(bool &$a, bool &$b, int $cond): void | |
{ | |
$sa = \gmp_init((int)($a), 10); | |
$sb = \gmp_init((int)($b), 10); | |
self::cswapGMP($sa, $sb, $cond); | |
$a = (bool)\gmp_strval($sa, 10); | |
$b = (bool)\gmp_strval($sb, 10); | |
} | |
public function isInfinity(): bool | |
{ | |
return $this->infinity; | |
} | |
public function getOrder(): \GMP | |
{ | |
return $this->order; | |
} | |
public function getX(): \GMP | |
{ | |
return $this->x; | |
} | |
public function getY(): \GMP | |
{ | |
return $this->y; | |
} | |
} | |
class Curve | |
{ | |
/** @var \GMP Elliptic curve over the field of integers modulo a prime. */ | |
private $a; | |
/** @var \GMP */ | |
private $b; | |
/** @var \GMP */ | |
private $prime; | |
/** @var int Binary length of keys associated with these curve parameters. */ | |
private $size; | |
/** @var Point */ | |
private $generator; | |
public function __construct(int $size, \GMP $prime, \GMP $a, \GMP $b, Point $generator) | |
{ | |
$this->size = $size; | |
$this->prime = $prime; | |
$this->a = $a; | |
$this->b = $b; | |
$this->generator = $generator; | |
} | |
public function getPublicKeyFrom(\GMP $x, \GMP $y): Point | |
{ | |
$zero = \gmp_init(0, 10); | |
if (Math::cmp($x, $zero) < 0 || Math::cmp($this->generator->getOrder(), $x) <= 0 || Math::cmp($y, $zero) < 0 || Math::cmp($this->generator->getOrder(), $y) <= 0) { | |
throw new \RuntimeException('Generator point has x and y out of range.'); | |
} | |
$point = $this->getPoint($x, $y); | |
return $point; | |
} | |
public function getPoint(\GMP $x, \GMP $y, \GMP $order = null): Point | |
{ | |
if (!$this->contains($x, $y)) { | |
throw new \RuntimeException('Curve ' . $this->__toString() . ' does not contain point (' . Math::toString($x) . ', ' . Math::toString($y) . ')'); | |
} | |
$point = Point::create($x, $y, $order); | |
if (!\is_null($order)) { | |
$this->mul($point, $order); | |
/* RuntimeException never reached - even with abstruse values in UnitTest | |
$mul = $this->mul($point, $order); | |
if (!$mul->isInfinity()) { | |
throw new \RuntimeException('SELF * ORDER MUST EQUAL INFINITY. (' . (string) $mul . ' found instead)'); | |
} | |
*/ | |
} | |
return $point; | |
} | |
public function contains(\GMP $x, \GMP $y): bool | |
{ | |
$eq_zero = Math::equals( | |
Math::modSub( | |
Math::pow($y, 2), | |
Math::add( | |
Math::add( | |
Math::pow($x, 3), | |
Math::mul($this->getA(), $x) | |
), | |
$this->getB() | |
), | |
$this->getPrime() | |
), | |
\gmp_init(0, 10) | |
); | |
return $eq_zero; | |
} | |
public function add(Point $one, Point $two): Point | |
{ | |
if ($two->isInfinity()) { | |
return clone $one; | |
} | |
if ($one->isInfinity()) { | |
return clone $two; | |
} | |
if (Math::equals($two->getX(), $one->getX())) { | |
if (Math::equals($two->getY(), $one->getY())) { | |
return $this->getDouble($one); | |
} else { | |
return Point::infinity(); | |
} | |
} | |
$slope = Math::modDiv( | |
Math::sub($two->getY(), $one->getY()), | |
Math::sub($two->getX(), $one->getX()), | |
$this->getPrime() | |
); | |
$xR = Math::modSub( | |
Math::sub(Math::pow($slope, 2), $one->getX()), | |
$two->getX(), | |
$this->getPrime() | |
); | |
$yR = Math::modSub( | |
Math::mul($slope, Math::sub($one->getX(), $xR)), | |
$one->getY(), | |
$this->getPrime() | |
); | |
return $this->getPoint($xR, $yR, $one->getOrder()); | |
} | |
public function getDouble(Point $point): Point | |
{ | |
if ($point->isInfinity()) { | |
return Point::infinity(); | |
} | |
$a = $this->getA(); | |
$threeX2 = Math::mul(\gmp_init(3, 10), Math::pow($point->getX(), 2)); | |
$tangent = Math::modDiv( | |
Math::add($threeX2, $a), | |
Math::mul(\gmp_init(2, 10), $point->getY()), | |
$this->getPrime() | |
); | |
$x3 = Math::modSub( | |
Math::pow($tangent, 2), | |
Math::mul(\gmp_init(2, 10), $point->getX()), | |
$this->getPrime() | |
); | |
$y3 = Math::modSub( | |
Math::mul($tangent, Math::sub($point->getX(), $x3)), | |
$point->getY(), | |
$this->getPrime() | |
); | |
return $this->getPoint($x3, $y3, $point->getOrder()); | |
} | |
public function getA(): \GMP | |
{ | |
return $this->a; | |
} | |
public function mul(Point $one, \GMP $n): Point | |
{ | |
if ($one->isInfinity()) { | |
return Point::infinity(); | |
} | |
/** @var \GMP $zero */ | |
$zero = \gmp_init(0, 10); | |
if (Math::cmp($one->getOrder(), $zero) > 0) { | |
$n = Math::mod($n, $one->getOrder()); | |
} | |
if (Math::equals($n, $zero)) { | |
return Point::infinity(); | |
} | |
/** @var Point[] $r */ | |
$r = [ | |
Point::infinity(), | |
clone $one, | |
]; | |
$k = $this->getSize(); | |
$n = \str_pad(Math::baseConvert(Math::toString($n), 10, 2), $k, '0', STR_PAD_LEFT); | |
for ($i = 0; $i < $k; ++$i) { | |
$j = (int)$n[$i]; | |
Point::cswap($r[0], $r[1], $j ^ 1); | |
$r[0] = $this->add($r[0], $r[1]); | |
$r[1] = $this->getDouble($r[1]); | |
Point::cswap($r[0], $r[1], $j ^ 1); | |
} | |
return $r[0]; | |
} | |
public function getSize(): int | |
{ | |
return $this->size; | |
} | |
public function getPrime(): \GMP | |
{ | |
return $this->prime; | |
} | |
public function getB(): \GMP | |
{ | |
return $this->b; | |
} | |
public function __toString(): string | |
{ | |
return 'curve(' . Math::toString($this->getA()) . ', ' . Math::toString($this->getB()) . ', ' . Math::toString($this->getPrime()) . ')'; | |
} | |
} | |
class NistCurve | |
{ | |
/** | |
* Returns an NIST P-256 curve. | |
*/ | |
public static function curve256(): Curve | |
{ | |
$p = \gmp_init('ffffffff00000001000000000000000000000000ffffffffffffffffffffffff', 16); | |
$a = \gmp_init('ffffffff00000001000000000000000000000000fffffffffffffffffffffffc', 16); | |
$b = \gmp_init('5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b', 16); | |
$x = \gmp_init('6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296', 16); | |
$y = \gmp_init('4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5', 16); | |
$n = \gmp_init('ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551', 16); | |
$generator = Point::create($x, $y, $n); | |
return new Curve(256, $p, $a, $b, $generator); | |
} | |
} | |
class PNEncryption | |
{ | |
use PNServerHelper; | |
/** max length of the payload */ | |
const MAX_PAYLOAD_LENGTH = 4078; | |
/** max compatible length of the payload */ | |
const MAX_COMPATIBILITY_PAYLOAD_LENGTH = 3052; | |
/** @var string public key from subscription */ | |
protected $strSubscrKey = ''; | |
/** @var string subscription authenthication code */ | |
protected $strSubscrAuth = ''; | |
/** @var string encoding 'aesgcm' / 'aes128gcm' */ | |
protected $strEncoding = ''; | |
/** @var string payload to encrypt */ | |
protected $strPayload = ''; | |
/** @var string local generated public key */ | |
protected $strLocalPublicKey = ''; | |
/** @var \GMP local generated private key */ | |
protected $gmpLocalPrivateKey; | |
/** @var string generated salt */ | |
protected $strSalt = ''; | |
/** @var string last error msg */ | |
protected $strError = ''; | |
/** | |
* @param string $strSubscrKey public key from subscription | |
* @param string $strSubscrAuth subscription authenthication code | |
* @param string $strEncoding encoding (default: 'aesgcm') | |
*/ | |
public function __construct(string $strSubscrKey, string $strSubscrAuth, string $strEncoding = 'aesgcm') | |
{ | |
$this->strSubscrKey = self::decodeBase64URL($strSubscrKey); | |
$this->strSubscrAuth = self::decodeBase64URL($strSubscrAuth); | |
$this->strEncoding = $strEncoding; | |
$this->strError = ''; | |
} | |
/** | |
* encrypt the payload. | |
* @param string $strPayload | |
* @return string|false encrypted string at success, false on any error | |
*/ | |
public function encrypt(string $strPayload) | |
{ | |
$this->strError = ''; | |
$this->strPayload = $strPayload; | |
$strContent = false; | |
// there's nothing to encrypt without payload... | |
if (strlen($strPayload) == 0) { | |
// it's OK - just set content-length of request to 0! | |
return ''; | |
} | |
if ($this->strEncoding !== 'aesgcm' && $this->strEncoding !== 'aes128gcm') { | |
$this->strError = "Encoding '" . $this->strEncoding . "' is not supported!"; | |
return false; | |
} | |
if (mb_strlen($this->strSubscrKey, '8bit') !== 65) { | |
$this->strError = "Invalid client public key length!"; | |
return false; | |
} | |
try { | |
// create random salt and local key pair | |
$this->strSalt = \random_bytes(16); | |
if (!$this->createLocalKey()) { | |
return false; | |
} | |
// create shared secret between local private key and public subscription key | |
$strSharedSecret = $this->getSharedSecret(); | |
// context and pseudo random key (PRK) to create content encryption key (CEK) and nonce | |
/* | |
* A nonce is a value that prevents replay attacks as it should only be used once. | |
* The content encryption key (CEK) is the key that will ultimately be used toencrypt | |
* our payload. | |
* @link https://en.wikipedia.org/wiki/Cryptographic_nonce | |
*/ | |
$context = $this->createContext(); | |
$prk = $this->getPRK($strSharedSecret); | |
// derive the encryption key | |
$cekInfo = $this->createInfo($this->strEncoding, $context); | |
$cek = self::hkdf($this->strSalt, $prk, $cekInfo, 16); | |
// and the nonce | |
$nonceInfo = $this->createInfo('nonce', $context); | |
$nonce = self::hkdf($this->strSalt, $prk, $nonceInfo, 12); | |
// pad payload ... from now payload converted to binary string | |
$strPayload = $this->padPayload($strPayload, self::MAX_COMPATIBILITY_PAYLOAD_LENGTH); | |
// encrypt | |
// "The additional data passed to each invocation of AEAD_AES_128_GCM is a zero-length octet sequence." | |
$strTag = ''; | |
$strEncrypted = openssl_encrypt($strPayload, 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $strTag); | |
// base64URL encode salt and local public key | |
$this->strSalt = self::encodeBase64URL($this->strSalt); | |
$this->strLocalPublicKey = self::encodeBase64URL($this->strLocalPublicKey); | |
$strContent = $this->getContentCodingHeader() . $strEncrypted . $strTag; | |
} catch (\RuntimeException $e) { | |
$this->strError = $e->getMessage(); | |
$strContent = false; | |
} | |
return $strContent; | |
} | |
/** | |
* create local public/private key pair using prime256v1 curve | |
* @return bool | |
*/ | |
private function createLocalKey(): bool | |
{ | |
$bSucceeded = false; | |
$keyResource = \openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); | |
if ($keyResource !== false) { | |
$details = \openssl_pkey_get_details($keyResource); | |
\openssl_pkey_free($keyResource); | |
if ($details !== false) { | |
$strLocalPublicKey = '04'; | |
$strLocalPublicKey .= str_pad(gmp_strval(gmp_init(bin2hex($details['ec']['x']), 16), 16), 64, '0', STR_PAD_LEFT); | |
$strLocalPublicKey .= str_pad(gmp_strval(gmp_init(bin2hex($details['ec']['y']), 16), 16), 64, '0', STR_PAD_LEFT); | |
$strLocalPublicKey = hex2bin($strLocalPublicKey); | |
if ($strLocalPublicKey !== false) { | |
$this->strLocalPublicKey = $strLocalPublicKey; | |
} | |
$this->gmpLocalPrivateKey = gmp_init(bin2hex($details['ec']['d']), 16); | |
$bSucceeded = true; | |
} | |
} | |
if (!$bSucceeded) { | |
$this->strError = 'openssl: ' . \openssl_error_string(); | |
} | |
return $bSucceeded; | |
} | |
/** | |
* build shared secret from user public key and local private key using prime256v1 curve | |
* @return string | |
*/ | |
private function getSharedSecret(): string | |
{ | |
$curve = NistCurve::curve256(); | |
$x = ''; | |
$y = ''; | |
self::getXYFromPublicKey($this->strSubscrKey, $x, $y); | |
$strSubscrKeyPoint = $curve->getPublicKeyFrom(\gmp_init(bin2hex($x), 16), \gmp_init(bin2hex($y), 16)); | |
// get shared secret from user public key and local private key | |
$strSharedSecret = $curve->mul($strSubscrKeyPoint, $this->gmpLocalPrivateKey); | |
$strSharedSecret = $strSharedSecret->getX(); | |
$strSharedSecret = hex2bin(str_pad(\gmp_strval($strSharedSecret, 16), 64, '0', STR_PAD_LEFT)); | |
return ($strSharedSecret !== false ? $strSharedSecret : ''); | |
} | |
/** | |
* 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}. | |
* | |
* @return null|string | |
* @throws \ErrorException | |
*/ | |
private function createContext(): ?string | |
{ | |
if ($this->strEncoding === "aes128gcm") { | |
return null; | |
} | |
// This one should never happen, because it's our code that generates the key | |
/* | |
if (mb_strlen($this->strLocalPublicKey, '8bit') !== 65) { | |
throw new \ErrorException('Invalid server public key length'); | |
} | |
*/ | |
$len = chr(0) . 'A'; // 65 as Uint16BE | |
return chr(0) . $len . $this->strSubscrKey . $len . $this->strLocalPublicKey; | |
} | |
/** | |
* get pseudo random key | |
* @param string $strSharedSecret | |
* @return string | |
*/ | |
private function getPRK(string $strSharedSecret): string | |
{ | |
if (!empty($this->strSubscrAuth)) { | |
if ($this->strEncoding === "aesgcm") { | |
$info = 'Content-Encoding: auth' . chr(0); | |
} else { | |
$info = "WebPush: info" . chr(0) . $this->strSubscrKey . $this->strLocalPublicKey; | |
} | |
$strSharedSecret = self::hkdf($this->strSubscrAuth, $strSharedSecret, $info, 32); | |
} | |
return $strSharedSecret; | |
} | |
/** | |
* 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'); | |
} | |
/** | |
* 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 $strType The type of the info record | |
* @param string|null $strContext The context for the record | |
* @return string | |
* @throws \ErrorException | |
*/ | |
private function createInfo(string $strType, ?string $strContext): string | |
{ | |
if ($this->strEncoding === "aesgcm") { | |
if (!$strContext) { | |
throw new \ErrorException('Context must exist'); | |
} | |
if (mb_strlen($strContext, '8bit') !== 135) { | |
throw new \ErrorException('Context argument has invalid size'); | |
} | |
$strInfo = 'Content-Encoding: ' . $strType . chr(0) . 'P-256' . $strContext; | |
} else { | |
$strInfo = 'Content-Encoding: ' . $strType . chr(0); | |
} | |
return $strInfo; | |
} | |
/** | |
* pad the payload. | |
* Before we encrypt our payload, we need to define how much padding we wish toadd to | |
* the front of the payload. The reason we?d want to add padding is that it prevents | |
* the risk of eavesdroppers being able to determine ?types? of messagesbased on the | |
* payload size. We must add two bytes of padding to indicate the length of any | |
* additionalpadding. | |
* | |
* @param string $strPayload | |
* @param int $iMaxLengthToPad | |
* @return string | |
*/ | |
private function padPayload(string $strPayload, int $iMaxLengthToPad = 0): string | |
{ | |
$iLen = mb_strlen($strPayload, '8bit'); | |
$iPad = $iMaxLengthToPad ? $iMaxLengthToPad - $iLen : 0; | |
if ($this->strEncoding === "aesgcm") { | |
$strPayload = pack('n*', $iPad) . str_pad($strPayload, $iPad + $iLen, chr(0), STR_PAD_LEFT); | |
} elseif ($this->strEncoding === "aes128gcm") { | |
$strPayload = str_pad($strPayload . chr(2), $iPad + $iLen, chr(0), STR_PAD_RIGHT); | |
} | |
return $strPayload; | |
} | |
/** | |
* get the content coding header to add to encrypted payload | |
* @return string | |
*/ | |
private function getContentCodingHeader(): string | |
{ | |
$strHeader = ''; | |
if ($this->strEncoding === "aes128gcm") { | |
$strHeader = $this->strSalt | |
. pack('N*', 4096) | |
. pack('C*', mb_strlen($this->strLocalPublicKey, '8bit')) | |
. $this->strLocalPublicKey; | |
} | |
return $strHeader; | |
} | |
/** | |
* Get headers for previous encrypted payload. | |
* Already existing headers (e.g. the VAPID-signature) can be passed through the input param | |
* and will be merged with the additional headers for the encryption | |
* | |
* @param array<string,string> $aHeaders existing headers to merge with | |
* @return array<string,string> | |
*/ | |
public function getHeaders(?array $aHeaders = null): array | |
{ | |
if (!$aHeaders) { | |
$aHeaders = array(); | |
} | |
if (strlen($this->strPayload) > 0) { | |
$aHeaders['Content-Type'] = 'application/octet-stream'; | |
$aHeaders['Content-Encoding'] = $this->strEncoding; | |
if ($this->strEncoding === "aesgcm") { | |
$aHeaders['Encryption'] = 'salt=' . $this->strSalt; | |
if (isset($aHeaders['Crypto-Key'])) { | |
$aHeaders['Crypto-Key'] = 'dh=' . $this->strLocalPublicKey . ';' . $aHeaders['Crypto-Key']; | |
} else { | |
$aHeaders['Crypto-Key'] = 'dh=' . $this->strLocalPublicKey; | |
} | |
} | |
} | |
return $aHeaders; | |
} | |
/** | |
* @return string last error | |
*/ | |
public function getError(): string | |
{ | |
return $this->strError; | |
} | |
} | |
class PNPayload | |
{ | |
use PNServerHelper; | |
/** @var array<mixed> */ | |
protected $aPayload; | |
/** | |
* Create instance of payload with title, text and icon to display. | |
* - title should be short and meaningfull. | |
* - The text should not increase 200 characters - the different browsers and | |
* platforms limit the display differently (partly according to the number of | |
* lines, others according to the number of characters) | |
* - icon should be square (if not, some browsers/platforms cut a square). There | |
* is no exact specification for the 'optimal' size, 64dp (px * device pixel ratio) | |
* should be a good decision (... 192px for highest device pixel ratio) | |
* | |
* @param string $strTitle Title to display | |
* @param string $strText A string representing an extra content to display within the notification. | |
* @param string $strIcon containing the URL of an image to be used as an icon by the notification. | |
*/ | |
public function __construct(string $strTitle, ?string $strText = null, ?string $strIcon = null) | |
{ | |
$this->aPayload = array( | |
'title' => $strTitle, | |
'opt' => array( | |
'body' => $strText, | |
'icon' => $strIcon, | |
), | |
); | |
} | |
/** | |
* Note: the URL is no part of the JS showNotification() - Options! | |
* @param string $strURL URL to open when user click on the notification. | |
*/ | |
public function setURL(string $strURL): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
if (!isset($this->aPayload['opt']['data']) || !is_array($this->aPayload['opt']['data'])) { | |
$this->aPayload['opt']['data'] = array(); | |
} | |
$this->aPayload['opt']['data']['url'] = $strURL; | |
} | |
} | |
/** | |
* An ID for a given notification that allows you to find, replace, or remove the notification using | |
* a script if necessary. | |
* If set, multiple notifications with the same tag will only reappear if $bReNotify is set to true. | |
* Usualy the last notification with same tag is displayed in this case. | |
* | |
* @param string $strTag | |
* @param bool $bReNotify | |
*/ | |
public function setTag(string $strTag, bool $bReNotify = false): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
$this->aPayload['opt']['tag'] = $strTag; | |
$this->aPayload['opt']['renotify'] = $bReNotify; | |
} | |
} | |
/** | |
* containing the URL of an larger image to be displayed in the notification. | |
* Size, position and cropping vary with the different browsers and platforms | |
* @param string $strImage | |
*/ | |
public function setImage(string $strImage): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
$this->aPayload['opt']['image'] = $strImage; | |
} | |
} | |
/** | |
* containing the URL of an badge assigend to the notification. | |
* The badge is a small monochrome icon that is used to portray a little | |
* more information to the user about where the notification is from. | |
* So far I have only found Chrome for Android that supports the badge... | |
* ... in most cases the browsers icon is displayed. | |
* | |
* @param string $strBadge | |
*/ | |
public function setBadge(string $strBadge): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
$this->aPayload['opt']['badge'] = $strBadge; | |
} | |
} | |
/** | |
* Add action to display in the notification. | |
* | |
* The count of action that can be displayed vary between browser/platform. On | |
* the client it can be detected with javascript: Notification.maxActions | |
* | |
* Appropriate responses have to be implemented within the notificationclick event. | |
* the event.action property contains the $strAction clicked on | |
* | |
* @param string $strAction identifying a user action to be displayed on the notification. | |
* @param string $strTitle containing action text to be shown to the user. | |
* @param string $strIcon containing the URL of an icon to display with the action. | |
* @param string $strCustom custom info - not part of the showNotification()- Options! | |
*/ | |
public function addAction(string $strAction, string $strTitle, ?string $strIcon = null, string $strCustom = ''): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
if (!isset($this->aPayload['opt']['actions']) || !is_array($this->aPayload['opt']['actions'])) { | |
$this->aPayload['opt']['actions'] = array(); | |
} | |
$this->aPayload['opt']['actions'][] = array('action' => $strAction, 'title' => $strTitle, 'icon' => $strIcon, 'custom' => $strCustom); | |
} | |
} | |
/** | |
* Set the time when the notification was created. | |
* It can be used to indicate the time at which a notification is actual. For example, this could | |
* be in the past when a notification is used for a message that couldn?t immediately be delivered | |
* because the device was offline, or in the future for a meeting that is about to start. | |
* | |
* @param mixed $timestamp DateTime object, UNIX timestamp or English textual datetime description | |
*/ | |
public function setTimestamp($timestamp): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
$iTimestamp = $timestamp; | |
if (self::className($timestamp) == 'DateTime') { | |
// DateTime -object | |
$iTimestamp = $timestamp->getTimestamp(); | |
} else if (is_string($timestamp)) { | |
// string | |
$iTimestamp = strtotime($timestamp); | |
} | |
// timestamp in milliseconds! | |
$this->aPayload['opt']['timestamp'] = bcmul((string)$iTimestamp, '1000'); | |
} | |
} | |
/** | |
* Indicates that on devices with sufficiently large screens, a notification should remain active until | |
* the user clicks or dismisses it. If this value is absent or false, the desktop version of Chrome | |
* will auto-minimize notifications after approximately twenty seconds. Implementation depends on | |
* browser and plattform. | |
* | |
* @param bool $bSet | |
*/ | |
public function requireInteraction(bool $bSet = true): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
$this->aPayload['opt']['requireInteraction'] = $bSet; | |
} | |
} | |
/** | |
* Indicates that no sounds or vibrations should be made. | |
* If this 'mute' function is activated, a previously set vibration is reset to prevent a TypeError exception. | |
* @param bool $bSet | |
*/ | |
public function setSilent(bool $bSet = true): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
$this->aPayload['opt']['silent'] = $bSet; | |
if ($bSet && isset($this->aPayload['opt']['vibrate'])) { | |
// silent=true and defined vibation causes TypeError | |
unset($this->aPayload['opt']['vibrate']); | |
} | |
} | |
} | |
/** | |
* A vibration pattern to run with the display of the notification. | |
* A vibration pattern can be an array with as few as one member. The values are times in milliseconds | |
* where the even indices (0, 2, 4, etc.) indicate how long to vibrate and the odd indices indicate | |
* how long to pause. For example, [300, 100, 400] would vibrate 300ms, pause 100ms, then vibrate 400ms. | |
* | |
* @param array<int> $aPattern | |
*/ | |
public function setVibration(array $aPattern): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
$this->aPayload['opt']['vibrate'] = $aPattern; | |
if (isset($this->aPayload['opt']['silent'])) { | |
// silent=true and vibation pattern causes TypeError | |
$this->aPayload['opt']['silent'] = false; | |
} | |
} | |
} | |
/** | |
* containing the URL of an sound - file (mp3 or wav). | |
* currently not found any browser supports sounds | |
* @param string $strSound | |
*/ | |
public function setSound(string $strSound): void | |
{ | |
if (is_array($this->aPayload) && isset($this->aPayload['opt']) && is_array($this->aPayload['opt'])) { | |
$this->aPayload['opt']['sound'] = $strSound; | |
} | |
} | |
/** | |
* Get the Payload data as array | |
* @return array<mixed> | |
*/ | |
public function getPayload(): array | |
{ | |
return $this->aPayload; | |
} | |
/** | |
* @return string JSON string representing payloal | |
*/ | |
public function __toString(): string | |
{ | |
return $this->toJSON(); | |
} | |
/** | |
* Convert payload dasta to JSON string. | |
* @return string JSON string representing payloal | |
*/ | |
public function toJSON(): string | |
{ | |
$strJson = json_encode($this->aPayload); | |
return utf8_encode($strJson !== false ? $strJson : ''); | |
} | |
} | |
class PNSubscription | |
{ | |
use PNServerHelper; | |
/** @var string the endpoint URL for the push notification */ | |
protected $strEndpoint = ''; | |
/** @var string public key */ | |
protected $strPublicKey = ''; | |
/** @var string authentification token */ | |
protected $strAuth = ''; | |
/** @var int unix timesatmp of expiration (0, if no expiration defined) */ | |
protected $timeExpiration = 0; | |
/** @var string encoding ('aesgcm' / 'aes128gcm') */ | |
protected $strEncoding = ''; | |
/** | |
* Use static method PNSubscription::fromJSON() instead of new-operator | |
* if data is available as JSON-string | |
* @param string $strEndpoint | |
* @param string $strPublicKey | |
* @param string $strAuth | |
* @param int $timeExpiration | |
* @param string $strEncoding | |
*/ | |
public function __construct(string $strEndpoint, string $strPublicKey, string $strAuth, int $timeExpiration = 0, string $strEncoding = 'aesgcm') | |
{ | |
$this->strEndpoint = $strEndpoint; | |
$this->strPublicKey = $strPublicKey; | |
$this->strAuth = $strAuth; | |
$this->timeExpiration = $timeExpiration; | |
$this->strEncoding = $strEncoding; | |
} | |
/** | |
* @param string $strJSON subscription as valid JSON string | |
* @return PNSubscription | |
*/ | |
public static function fromJSON(string $strJSON): PNSubscription | |
{ | |
$strEndpoint = ''; | |
$strPublicKey = ''; | |
$strAuth = ''; | |
$timeExpiration = 0; | |
$aJSON = json_decode($strJSON, true); | |
if (isset($aJSON['endpoint'])) { | |
$strEndpoint = $aJSON['endpoint']; | |
} | |
if (isset($aJSON['expirationTime'])) { | |
$timeExpiration = (int)bcdiv($aJSON['expirationTime'], '1000'); | |
} | |
if (isset($aJSON['keys'])) { | |
if (isset($aJSON['keys']['p256dh'])) { | |
$strPublicKey = $aJSON['keys']['p256dh']; | |
} | |
if (isset($aJSON['keys']['auth'])) { | |
$strAuth = $aJSON['keys']['auth']; | |
} | |
} | |
return new self($strEndpoint, $strPublicKey, $strAuth, $timeExpiration); | |
} | |
/** | |
* extract origin from endpoint | |
* @param string $strEndpoint endpoint URL | |
* @return string | |
*/ | |
public static function getOrigin(string $strEndpoint): string | |
{ | |
return parse_url($strEndpoint, PHP_URL_SCHEME) . '://' . parse_url($strEndpoint, PHP_URL_HOST); | |
} | |
/** | |
* basic check if object containing valid data | |
* - endpoint, public key and auth token must be set | |
* - only encoding 'aesgcm' or 'aes128gcm' supported | |
* @return bool | |
*/ | |
public function isValid(): bool | |
{ | |
$bValid = false; | |
if (!$this->isExpired()) { | |
$bValid = ( | |
isset($this->strEndpoint, $this->strPublicKey) && strlen($this->strEndpoint) > 0 && strlen($this->strPublicKey) > 0 && isset($this->strAuth) && strlen($this->strAuth) > 0 && ($this->strEncoding == 'aesgcm' || $this->strEncoding == 'aes128gcm') | |
); | |
} | |
return $bValid; | |
} | |
/** | |
* @return bool | |
*/ | |
public function isExpired(): bool | |
{ | |
return ($this->timeExpiration != 0 && $this->timeExpiration < time()); | |
} | |
/** | |
* @return string | |
*/ | |
public function getEndpoint(): string | |
{ | |
return $this->strEndpoint; | |
} | |
/** | |
* @return string | |
*/ | |
public function getPublicKey(): string | |
{ | |
return $this->strPublicKey; | |
} | |
/** | |
* @return string | |
*/ | |
public function getAuth(): string | |
{ | |
return $this->strAuth; | |
} | |
/** | |
* @return string | |
*/ | |
public function getEncoding(): string | |
{ | |
return $this->strEncoding; | |
} | |
/** | |
* @param string $strEndpoint | |
*/ | |
public function setEndpoint(string $strEndpoint): void | |
{ | |
$this->strEndpoint = $strEndpoint; | |
} | |
/** | |
* @param string $strPublicKey | |
*/ | |
public function setPublicKey(string $strPublicKey): void | |
{ | |
$this->strPublicKey = $strPublicKey; | |
} | |
/** | |
* @param string $strAuth | |
*/ | |
public function setAuth(string $strAuth): void | |
{ | |
$this->strAuth = $strAuth; | |
} | |
/** | |
* @param int $timeExpiration | |
*/ | |
public function setExpiration(int $timeExpiration): void | |
{ | |
$this->timeExpiration = $timeExpiration; | |
} | |
/** | |
* @param string $strEncoding | |
*/ | |
public function setEncoding(string $strEncoding): void | |
{ | |
$this->strEncoding = $strEncoding; | |
} | |
} | |
class PNServer | |
{ | |
use PNServerHelper; | |
/** @var PNVapid */ | |
protected $oVapid = null; | |
/** @var string */ | |
protected $strPayload = ''; | |
/** @var array<PNSubscription> */ | |
protected $aSubscription = []; | |
/** @var array<string,array<string,mixed>> */ | |
protected $aLog = []; | |
/** @var int $iAutoRemoved count of items autoremoved in loadSubscriptions */ | |
protected $iAutoRemoved = 0; | |
/** @var int $iExpired count of expired items */ | |
protected $iExpired = 0; | |
/** @var string last error msg */ | |
protected $strError = ''; | |
/** @var bool auto remove invalid/expired subscriptions */ | |
protected $bAutoRemove = true; | |
public function __construct() | |
{ | |
$this->reset(); | |
} | |
/** | |
* reset ll to begin new push notification. | |
*/ | |
public function reset(): void | |
{ | |
$this->strPayload = ''; | |
$this->oVapid = null; | |
$this->aSubscription = []; | |
} | |
/** | |
* set VAPID subject and keys. | |
* @param PNVapid $oVapid | |
*/ | |
public function setVapid(PNVapid $oVapid): void | |
{ | |
$this->oVapid = $oVapid; | |
} | |
/** | |
* set payload used for all push notifications. | |
* @param mixed $payload string or PNPayload object | |
*/ | |
public function setPayload($payload): void | |
{ | |
if (is_string($payload) || self::className($payload) == 'PNPayload') { | |
$this->strPayload = (string)$payload; | |
} | |
} | |
/** | |
* @return string | |
*/ | |
public function getPayload(): string | |
{ | |
return $this->strPayload; | |
} | |
/** | |
* add subscription to the notification list. | |
* @param PNSubscription $oSubscription | |
*/ | |
public function addSubscription(PNSubscription $oSubscription): void | |
{ | |
if ($oSubscription->isValid()) { | |
$this->aSubscription[] = $oSubscription; | |
} | |
} | |
/** | |
* Get the count of valid subscriptions set. | |
* @return int | |
*/ | |
public function getSubscriptionCount(): int | |
{ | |
return count($this->aSubscription); | |
} | |
/** | |
* push all notifications. | |
* | |
* Since a large number is expected when sending PUSH notifications, the | |
* POST requests are generated asynchronously via a cURL multi handle. | |
* The response codes are then assigned to the respective end point and a | |
* transmission log is generated. | |
* If the subscriptions comes from the internal data provider, all | |
* subscriptions that are no longer valid or that are no longer available | |
* with the push service will be removed from the database. | |
* @return bool | |
*/ | |
public function push(): bool | |
{ | |
if (!$this->oVapid) { | |
$this->strError = 'no VAPID-keys set!'; | |
$this->logger->error(__CLASS__ . ': ' . $this->strError); | |
} elseif (!$this->oVapid->isValid()) { | |
$this->strError = 'VAPID error: ' . $this->oVapid->getError(); | |
$this->logger->error(__CLASS__ . ': ' . $this->strError); | |
} elseif (count($this->aSubscription) == 0) { | |
$this->strError = 'no valid Subscriptions set!'; | |
$this->logger->warning(__CLASS__ . ': ' . $this->strError); | |
} else { | |
// create multi requests... | |
$mcurl = curl_multi_init(); | |
if ($mcurl !== false) { | |
$aRequests = array(); | |
foreach ($this->aSubscription as $oSub) { | |
$aLog = ['msg' => '', 'curl_response' => '', 'curl_response_code' => -1]; | |
// payload must be encrypted every time although it does not change, since | |
// each subscription has at least his public key and authentication token of its own ... | |
$oEncrypt = new PNEncryption($oSub->getPublicKey(), $oSub->getAuth(), $oSub->getEncoding()); | |
if (($strContent = $oEncrypt->encrypt($this->strPayload)) !== false) { | |
// merge headers from encryption and VAPID (maybe both containing 'Crypto-Key') | |
if (($aVapidHeaders = $this->oVapid->getHeaders($oSub->getEndpoint())) !== false) { | |
$aHeaders = $oEncrypt->getHeaders($aVapidHeaders); | |
$aHeaders['Content-Length'] = mb_strlen($strContent, '8bit'); | |
$aHeaders['TTL'] = 2419200; | |
// build Http - Headers | |
$aHttpHeader = array(); | |
foreach ($aHeaders as $strName => $strValue) { | |
$aHttpHeader[] = $strName . ': ' . $strValue; | |
} | |
// and send request with curl | |
$curl = curl_init($oSub->getEndpoint()); | |
if ($curl !== false) { | |
curl_setopt($curl, CURLOPT_POST, true); | |
curl_setopt($curl, CURLOPT_POSTFIELDS, $strContent); | |
curl_setopt($curl, CURLOPT_HTTPHEADER, $aHttpHeader); | |
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); | |
curl_multi_add_handle($mcurl, $curl); | |
$aRequests[$oSub->getEndpoint()] = $curl; | |
} | |
} else { | |
$aLog['msg'] = 'VAPID error: ' . $this->oVapid->getError(); | |
} | |
} else { | |
$aLog['msg'] = 'Payload encryption error: ' . $oEncrypt->getError(); | |
} | |
if (strlen($aLog['msg']) > 0) { | |
$this->aLog[$oSub->getEndpoint()] = $aLog; | |
} | |
} | |
if (count($aRequests) > 0) { | |
// now performing multi request... | |
$iRunning = null; | |
do { | |
$iMState = curl_multi_exec($mcurl, $iRunning); | |
} while ($iRunning && $iMState == CURLM_OK); | |
if ($iMState == CURLM_OK) { | |
// ...and get response of each request | |
foreach ($aRequests as $strEndPoint => $curl) { | |
$aLog = array(); | |
$iRescode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); | |
$aLog['msg'] = $this->getPushServiceResponseText($iRescode); | |
$aLog['curl_response'] = curl_multi_getcontent($curl); | |
$aLog['curl_response_code'] = $iRescode; | |
$this->aLog[$strEndPoint] = $aLog; | |
// remove handle from multi and close | |
curl_multi_remove_handle($mcurl, $curl); | |
curl_close($curl); | |
} | |
} else { | |
$this->strError = 'curl_multi_exec() Erroro: ' . curl_multi_strerror($iMState); | |
$this->logger->error(__CLASS__ . ': ' . $this->strError); | |
} | |
// ... close the door | |
curl_multi_close($mcurl); | |
} | |
} | |
} | |
return (strlen($this->strError) == 0); | |
} | |
/** | |
* @return string last error | |
*/ | |
public function getError(): string | |
{ | |
return $this->strError; | |
} | |
/** | |
* get text according to given push service responsecode | |
* | |
* push service response codes | |
* 201: The request to send a push message was received and accepted. | |
* 400: Invalid request. This generally means one of your headers is invalid or improperly formatted. | |
* 404: Not Found. This is an indication that the subscription is expired and can't be used. In this case | |
* you should delete the PushSubscription and wait for the client to resubscribe the user. | |
* 410: Gone. The subscription is no longer valid and should be removed from application server. This can | |
* be reproduced by calling `unsubscribe()` on a `PushSubscription`. | |
* 413: Payload size too large. The minimum size payload a push service must support is 4096 bytes (or 4kb). | |
* 429: Too many requests. Meaning your application server has reached a rate limit with a push service. | |
* The push service should include a 'Retry-After' header to indicate how long before another request | |
* can be made. | |
* | |
* @param int $iRescode | |
* @return string | |
*/ | |
protected function getPushServiceResponseText(int $iRescode): string | |
{ | |
$strText = 'unknwown Rescode from push service: ' . $iRescode; | |
$aText = array( | |
201 => "The request to send a push message was received and accepted.", | |
400 => "Invalid request. Invalid headers or improperly formatted.", | |
404 => "Not Found. Subscription is expired and can't be used anymore.", | |
410 => "Gone. Subscription is no longer valid.", // This can be reproduced by calling 'unsubscribe()' on a 'PushSubscription'. | |
413 => "Payload size too large.", | |
429 => "Too many requests. Your application server has reached a rate limit with a push service." | |
); | |
if (isset($aText[$iRescode])) { | |
$strText = $aText[$iRescode]; | |
} | |
return $strText; | |
} | |
/** | |
* Push one single subscription. | |
* @param PNSubscription $oSub | |
* @return bool | |
*/ | |
public function pushSingle(PNSubscription $oSub): bool | |
{ | |
if (!$this->oVapid) { | |
$this->strError = 'no VAPID-keys set!'; | |
} elseif (!$this->oVapid->isValid()) { | |
$this->strError = 'VAPID error: ' . $this->oVapid->getError(); | |
} else { | |
$aLog = ['msg' => '', 'curl_response' => '', 'curl_response_code' => -1]; | |
// payload must be encrypted every time although it does not change, since | |
// each subscription has at least his public key and authentication token of its own ... | |
$oEncrypt = new PNEncryption($oSub->getPublicKey(), $oSub->getAuth(), $oSub->getEncoding()); | |
if (($strContent = $oEncrypt->encrypt($this->strPayload)) !== false) { | |
// merge headers from encryption and VAPID (maybe both containing 'Crypto-Key') | |
if (($aVapidHeaders = $this->oVapid->getHeaders($oSub->getEndpoint())) !== false) { | |
$aHeaders = $oEncrypt->getHeaders($aVapidHeaders); | |
$aHeaders['Content-Length'] = mb_strlen($strContent, '8bit'); | |
$aHeaders['TTL'] = 2419200; | |
// build Http - Headers | |
$aHttpHeader = array(); | |
foreach ($aHeaders as $strName => $strValue) { | |
$aHttpHeader[] = $strName . ': ' . $strValue; | |
} | |
// and send request with curl | |
$curl = curl_init($oSub->getEndpoint()); | |
if ($curl !== false) { | |
curl_setopt($curl, CURLOPT_POST, true); | |
curl_setopt($curl, CURLOPT_POSTFIELDS, $strContent); | |
curl_setopt($curl, CURLOPT_HTTPHEADER, $aHttpHeader); | |
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); | |
if (($strResponse = curl_exec($curl)) !== false) { | |
$iRescode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); | |
$aLog['msg'] = $this->getPushServiceResponseText($iRescode); | |
$aLog['curl_response'] = $strResponse; | |
$aLog['curl_response_code'] = $iRescode; | |
curl_close($curl); | |
} | |
} | |
} else { | |
$aLog['msg'] = 'VAPID error: ' . $this->oVapid->getError(); | |
} | |
} else { | |
$aLog['msg'] = 'Payload encryption error: ' . $oEncrypt->getError(); | |
} | |
$this->aLog[$oSub->getEndpoint()] = $aLog; | |
} | |
return (strlen($this->strError) == 0); | |
} | |
/** | |
* @return array<string,array<string,mixed>> | |
*/ | |
public function getLog(): array | |
{ | |
return $this->aLog; | |
} | |
/** | |
* Build summary for the log of the last push operation. | |
* - total count of subscriptions processed<br/> | |
* - count of successfull pushed messages<br/> | |
* - count of failed messages (subscriptions couldn't be pushed of any reason)<br/> | |
* - count of expired subscriptions<br/> | |
* - count of removed subscriptions (expired, gone, not found, invalid)<br/> | |
* The count of expired entries removed in the loadSubscriptions() is added to | |
* the count of responsecode caused removed items. | |
* The count of failed and removed messages may differ even if $bAutoRemove is set | |
* if there are transferns with responsecode 413 or 429 | |
* @return array<string,int> | |
*/ | |
public function getSummary(): array | |
{ | |
$aSummary = [ | |
'total' => $this->iExpired, | |
'pushed' => 0, | |
'failed' => 0, | |
'expired' => $this->iExpired, | |
'removed' => $this->iAutoRemoved, | |
]; | |
foreach ($this->aLog as $aLogItem) { | |
$aSummary['total']++; | |
if ($aLogItem['curl_response_code'] == 201) { | |
$aSummary['pushed']++; | |
} else { | |
$aSummary['failed']++; | |
if ($this->checkAutoRemove($aLogItem['curl_response_code'])) { | |
$aSummary['removed']++; | |
} | |
} | |
} | |
return $aSummary; | |
} | |
/** | |
* Check if item should be removed. | |
* We remove items with responsecode<br/> | |
* -> 0: unknown responsecode (usually unknown/invalid endpoint origin)<br/> | |
* -> -1: Payload encryption error<br/> | |
* -> 400: Invalid request<br/> | |
* -> 404: Not Found<br/> | |
* -> 410: Gone<br/> | |
* | |
* @param int $iRescode | |
* @return bool | |
*/ | |
protected function checkAutoRemove(int $iRescode): bool | |
{ | |
$aRemove = $this->bAutoRemove ? [-1, 0, 400, 404, 410] : []; | |
return in_array($iRescode, $aRemove); | |
} | |
} | |
$myVapid = new PNVapid('mailto:test@test.com', 'BJthRQ5myDgc7OSXzPCMftGw-n16F7zQBEN7EUD6XxcfTTvrLGWSIG7y_JxiWtVlCFua0S8MTB5rPziBqNx1qIo', '3KzvKasA2SoCxsp0iIG_o9B0Ozvl1XDwI63JRKNIWBM'); | |
$myServer = new PNServer(); | |
$myServer->setVapid($myVapid); | |
$myPayload = new PNPayload('Ore', 'Nazrul naki kaj kore na???', 'https://www.google.de/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png'); | |
$myServer->setPayload($myPayload); | |
$myServer->pushSingle(new PNSubscription( | |
'https://fcm.googleapis.com/fcm/send/eFrjCXDyHaY:APA91bEvdwtMDFNjPNkuZwXo2imOtE_wQkIiiRK5J5vcU1AXwy7EUDuVlIKpHUQ9zfZPGRfg_WSwYpVmnIQnBLJH_pN-AWgeVS5vb6agFGKIMCgbIflbXVq49lbcK51uH_mh7f8LE2um', | |
'BGtZpCwwc5xS-pxHlCgmltjcwH9HS6orP70mChFAu2G5Bp0oyaBx2k5tk8UexI8T3JSCrr0soETMhKqMVKZIsyY', | |
'yci15Uhk0sGnE2KtSVWHig' | |
)); | |
$aLog = $myServer->getLog(); | |
echo '<h2>Push - Log:</h2>' . PHP_EOL; | |
foreach ($aLog as $strEndpoint => $aMsg) { | |
echo PNSubscription::getOrigin($strEndpoint) . ': ' . $aMsg['msg'] . '<br/>' . PHP_EOL; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Useful Class