Skip to content

Instantly share code, notes, and snippets.

@md-riaz
Created December 21, 2023 03:46
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save md-riaz/52861c47f7387babe50d62cbce6b0458 to your computer and use it in GitHub Desktop.
Save md-riaz/52861c47f7387babe50d62cbce6b0458 to your computer and use it in GitHub Desktop.
<?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) . ':&nbsp;' . $aMsg['msg'] . '<br/>' . PHP_EOL;
}
@iammashikur
Copy link

Useful Class

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment