Skip to content

Commit

Permalink
Merge pull request #1 from Minishlink/payload
Browse files Browse the repository at this point in the history
Payload support with Firefox 46+ and Chrome 50+
  • Loading branch information
Minishlink committed Apr 28, 2016
2 parents eb5902d + 006fdde commit 96bf635
Show file tree
Hide file tree
Showing 9 changed files with 519 additions and 109 deletions.
3 changes: 2 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ php:
- 5.5
- 5.6
- hhvm
- 7.0

before_script:
- composer install --prefer-source -n
- composer install --prefer-source -n --no-dev

script: phpunit -c phpunit.travis.xml
92 changes: 71 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,45 +9,67 @@

## Usage
WebPush can be used to send notifications to endpoints which server delivers web push notifications as described in
the [Web Push API specification](http://www.w3.org/TR/push-api/).
the [Web Push protocol](https://tools.ietf.org/html/draft-thomson-webpush-protocol-00).
As it is standardized, you don't have to worry about what server type it relies on.

__*Currently, WebPush doesn't support payloads at all.
It is under development (see ["payload" branch](https://github.com/Minishlink/web-push/tree/payload)).
PHP 7.1 will be needed for some encryption features.*__
Development of payload support is stopped until [this PHP bug](https://bugs.php.net/bug.php?id=67304) is fixed.
If you need to show custom info in your notifications, you will have to fetch this info from your server in your Service
Worker when displaying the notification (see [this example](https://github.com/Minishlink/physbook/blob/e98ac7c3b7dd346eee1f315b8723060e8a3fc5cb/web/service-worker.js#L75)).
Notifications with payloads are supported with this library on Firefox 46+ and Chrome 50+.

```php
<?php

use Minishlink\WebPush\WebPush;

// array of endpoints
$endpoints = array(
'https://android.googleapis.com/gcm/send/abcdef...', // Chrome
'https://updates.push.services.mozilla.com/push/adcdef...', // Firefox 43+
'https://example.com/other/endpoint/of/another/vendor/abcdef...',
// array of notifications
$notifications = array(
array(
'endpoint' => 'https://updates.push.services.mozilla.com/push/abc...', // Firefox 43+
'payload' => 'hello !',
'userPublicKey' => 'BPcMbnWQL5GOYX/5LKZXT6sLmHiMsJSiEvIFvfcDvX7IZ9qqtq68onpTPEYmyxSQNiH7UD/98AUcQ12kBoxz/0s=', // base 64 encoded, should be 88 chars
'userAuthToken' => 'CxVX6QsVToEGEcjfYPqXQw==', // base 64 encoded, should be 24 chars
), array(
'endpoint' => 'https://android.googleapis.com/gcm/send/abcdef...', // Chrome
'payload' => null,
'userPublicKey' => null,
'userAuthToken' => null,
), array(
'endpoint' => 'https://example.com/other/endpoint/of/another/vendor/abcdef...',
'payload' => '{msg:"test"}',
'userPublicKey' => '(stringOf88Chars)',
'userAuthToken' => '(stringOf24Chars)',
),
);

$webPush = new WebPush();

// send multiple notifications
foreach ($endpoints as $endpoint) {
$webPush->sendNotification($endpoint);
// send multiple notifications with payload
foreach ($notifications as $notification) {
$webPush->sendNotification(
$notification['endpoint'],
$notification['payload'], // optional (defaults null)
$notification['userPublicKey'], // optional (defaults null)
$notification['userAuthToken'] // optional (defaults null)
);
}
$webPush->flush();

// send one notification and flush directly
$webPush->sendNotification($endpoints[0], null, null, true);
$webPush->sendNotification(
$notifications[0]['endpoint'],
$notifications[0]['payload'], // optional (defaults null)
$notifications[0]['userPublicKey'], // optional (defaults null)
$notifications[0]['userAuthToken'], // optional (defaults null)
true // optional (defaults false)
);
```

### Client side implementation of Web Push
There are several good examples and tutorials on the web:
* Mozilla's [ServiceWorker Cookbooks](https://serviceworke.rs/push-payload.html) (outdated as of 03-20-2016, because it does not take into account the user auth secret)
* Google's [introduction to push notifications](https://developers.google.com/web/fundamentals/getting-started/push-notifications/) (as of 03-20-2016, it doesn't mention notifications with payload)
* you may take a look at my own implementation: [sw.js](https://github.com/Minishlink/physbook/blob/07433bdb5fe4e3c7a6e4465c74e3b07c5a12886c/web/service-worker.js) and [app.js](https://github.com/Minishlink/physbook/blob/2a468273665a241ddc9aa2e12c57d18cd842d965/app/Resources/public/js/app.js) (payload sent indirectly)

### GCM servers notes (Chrome)
For compatibility reasons, this library detects if the server is a GCM server and appropriately sends the notification.
GCM servers don't support encrypted payloads yet so WebPush will skip the payload.
If you still want to show that payload on your notification, you should get that data on client-side from your server
where you will have to store somewhere the history of notifications.

You will need to specify your GCM api key when instantiating WebPush:
```php
Expand All @@ -61,15 +83,34 @@ $apiKeys = array(
);

$webPush = new WebPush($apiKeys);
$webPush->sendNotification($endpoint, null, null, true);
$webPush->sendNotification($endpoint, null, null, null, true);
```

### Payload length and security
Payload will be encrypted by the library. The maximum payload length is 4078 bytes (or ASCII characters).

However, when you encrypt a string of a certain length, the resulting string will always have the same length,
no matter how many times you encrypt the initial string. This can make attackers guess the content of the payload.
In order to circumvent this, this library can add some null padding to the initial payload, so that all the input of the encryption process
will have the same length. This way, all the output of the encryption process will also have the same length and attackers won't be able to
guess the content of your payload. The downside of this approach is that you will use more bandwidth than if you didn't pad the string.
That's why the library provides the option to disable this security measure:

```php
<?php

use Minishlink\WebPush\WebPush;

$webPush = new WebPush();
$webPush->setAutomaticPadding(false); // disable automatic padding
```

### Time To Live
Time To Live (TTL, in seconds) is how long a push message is retained by the push service (eg. Mozilla) in case the user browser
is not yet accessible (eg. is not connected). You may want to use a very long time for important notifications. The default TTL is 4 weeks.
However, if you send multiple nonessential notifications, set a TTL of 0: the push notification will be delivered only
if the user is currently connected. For other cases, you should use a minimum of one day if your users have multiple time
zones, and if you don't several hours will suffice.
zones, and if they don't several hours will suffice.

```php
<?php
Expand Down Expand Up @@ -125,6 +166,15 @@ Feel free to add your own!
### Is the API stable?
Not until the [Push API spec](http://www.w3.org/TR/push-api/) is finished.

### What about security?
Payload is encrypted according to the [Message Encryption for Web Push](https://tools.ietf.org/html/draft-ietf-webpush-encryption-01) standard,
using the user public key and authentication secret that you can get by following the [Web Push API](http://www.w3.org/TR/push-api/) specification.

Internally, WebPush uses the [phpecc](https://github.com/phpecc/phpecc) Elliptic Curve Cryptography library to create
local public and private keys and compute the shared secret.
Then, if you have a PHP >= 7.1, WebPush uses `openssl` in order to encrypt the payload with the encryption key.
Otherwise, if you have PHP < 7.1, it uses [Spomky-Labs/php-aes-gcm](https://github.com/Spomky-Labs/php-aes-gcm), which is slower.

### How to solve "SSL certificate problem: unable to get local issuer certificate" ?
Your installation lacks some certificates.

Expand Down
8 changes: 6 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "minishlink/web-push",
"type": "library",
"description": "Web Push library for PHP",
"keywords": ["push", "notifications", "web"],
"keywords": ["push", "notifications", "web", "WebPush", "Push API"],
"homepage": "https://github.com/Minishlink/web-push",
"license": "MIT",
"authors": [
Expand All @@ -14,7 +14,11 @@
],
"require": {
"php": ">=5.4",
"kriswallsmith/buzz": ">=0.6"
"kriswallsmith/buzz": ">=0.6",
"mdanter/ecc": "^0.3.0",
"lib-openssl": "*",
"spomky-labs/base64url": "^1.0",
"spomky-labs/php-aes-gcm": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "4.8.*"
Expand Down
6 changes: 5 additions & 1 deletion phpunit.dist.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
</testsuites>
<php>
<env name="STANDARD_ENDPOINT" value="" />
<env name="GCM_ENDPOINT" value="" />
<env name="USER_PUBLIC_KEY" value="" />
<env name="USER_AUTH_TOKEN" value="" />

<env name="GCM_ENDPOINT" value="" />
<env name="GCM_USER_PUBLIC_KEY" value="" />
<env name="GCM_USER_AUTH_TOKEN" value="" />
<env name="GCM_API_KEY" value="" />
</php>
</phpunit>
172 changes: 172 additions & 0 deletions src/Encryption.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

/*
* This file is part of the WebPush library.
*
* (c) Louis Lagrange <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Minishlink\WebPush;

use Base64Url\Base64Url;
use Mdanter\Ecc\EccFactory;
use Mdanter\Ecc\Serializer\Point\UncompressedPointSerializer;

final class Encryption
{
const MAX_PAYLOAD_LENGTH = 4078;

/**
* @param string $payload
* @param bool $automatic
* @return string padded payload (plaintext)
*/
public static function padPayload($payload, $automatic)
{
$payloadLen = strlen($payload);
$padLen = $automatic ? self::MAX_PAYLOAD_LENGTH - $payloadLen : 0;
return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
}

/**
* @param string $payload With padding
* @param string $userPublicKey MIME base 64 encoded
* @param string $userAuthToken MIME base 64 encoded
* @param bool $nativeEncryption Use OpenSSL (>PHP7.1)
*
* @return array
*/
public static function encrypt($payload, $userPublicKey, $userAuthToken, $nativeEncryption)
{
$userPublicKey = base64_decode($userPublicKey);
$userAuthToken = base64_decode($userAuthToken);

// initialize utilities
$math = EccFactory::getAdapter();
$pointSerializer = new UncompressedPointSerializer($math);
$generator = EccFactory::getNistCurves()->generator256();
$curve = EccFactory::getNistCurves()->curve256();

// get local key pair
$localPrivateKeyObject = $generator->createPrivateKey();
$localPublicKeyObject = $localPrivateKeyObject->getPublicKey();
$localPublicKey = hex2bin($pointSerializer->serialize($localPublicKeyObject->getPoint()));

// get user public key object
$pointUserPublicKey = $pointSerializer->unserialize($curve, bin2hex($userPublicKey));
$userPublicKeyObject = $generator->getPublicKeyFrom($pointUserPublicKey->getX(), $pointUserPublicKey->getY(), $generator->getOrder());

// get shared secret from user public key and local private key
$sharedSecret = hex2bin($math->decHex($userPublicKeyObject->getPoint()->mul($localPrivateKeyObject->getSecret())->getX()));

// generate salt
$salt = openssl_random_pseudo_bytes(16);

// section 4.3
$ikm = !empty($userAuthToken) ?
self::hkdf($userAuthToken, $sharedSecret, 'Content-Encoding: auth'.chr(0), 32) :
$sharedSecret;

// section 4.2
$context = self::createContext($userPublicKey, $localPublicKey);

// derive the Content Encryption Key
$contentEncryptionKeyInfo = self::createInfo('aesgcm', $context);
$contentEncryptionKey = self::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16);

// section 3.3, derive the nonce
$nonceInfo = self::createInfo('nonce', $context);
$nonce = self::hkdf($salt, $ikm, $nonceInfo, 12);

// encrypt
// "The additional data passed to each invocation of AEAD_AES_128_GCM is a zero-length octet sequence."
if (!$nativeEncryption) {
list($encryptedText, $tag) = \AESGCM\AESGCM::encrypt($contentEncryptionKey, $nonce, $payload, "");
} else {
$encryptedText = openssl_encrypt($payload, 'aes-128-gcm', $contentEncryptionKey, OPENSSL_RAW_DATA, $nonce, $tag); // base 64 encoded
}

// return values in url safe base64
return array(
'localPublicKey' => Base64Url::encode($localPublicKey),
'salt' => Base64Url::encode($salt),
'cipherText' => $encryptedText.$tag,
);
}

/**
* 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 $salt string A non-secret random value
* @param $ikm string Input keying material
* @param $info string Application-specific context
* @param $length int The length (in bytes) of the required output key
* @return string
*/
private static function hkdf($salt, $ikm, $info, $length)
{
// extract
$prk = hash_hmac('sha256', $ikm, $salt, true);

// expand
return substr(hash_hmac('sha256', $info.chr(1), $prk, true), 0, $length);
}

/**
* Creates a context for deriving encyption parameters.
* See section 4.2 of
* {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
*
* @param $clientPublicKey string The client's public key
* @param $serverPublicKey string Our public key
* @return string
* @throws \ErrorException
*/
private static function createContext($clientPublicKey, $serverPublicKey)
{
if (strlen($clientPublicKey) !== 65) {
throw new \ErrorException('Invalid client public key length');
}

// This one should never happen, because it's our code that generates the key
if (strlen($serverPublicKey) !== 65) {
throw new \ErrorException('Invalid server public key length');
}

$len = chr(0).'A'; // 65 as Uint16BE

return chr(0).$len.$clientPublicKey.$len.$serverPublicKey;
}

/**
* Returns an info record. See sections 3.2 and 3.3 of
* {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
*
* @param $type string The type of the info record
* @param $context string The context for the record
* @return string
* @throws \ErrorException
*/
private static function createInfo($type, $context) {
if (strlen($context) !== 135) {
throw new \ErrorException('Context argument has invalid size');
}

return 'Content-Encoding: '.$type.chr(0).'P-256'.$context;
}
}
Loading

0 comments on commit 96bf635

Please sign in to comment.