Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Payload support #1

Merged
merged 63 commits into from
Apr 28, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
d099062
update readme for payload support
Minishlink Nov 27, 2015
e8532f0
add phpecc library to composer require
Minishlink Nov 27, 2015
a8c3c13
add tests
Minishlink Nov 27, 2015
8b1f5fc
started payload support and encryption
Minishlink Nov 27, 2015
53444ff
fix typo
Minishlink Nov 27, 2015
5c88f17
update keywords
Minishlink Nov 28, 2015
c389e4c
fix test encrypt
Minishlink Dec 1, 2015
8d55f0a
encrypt with openssl instead of mcrypt
Minishlink Dec 1, 2015
5b8958d
update phpdoc
Minishlink Dec 1, 2015
cf34672
update security infos in README
Minishlink Dec 1, 2015
ee76313
add openssl dependency
Minishlink Dec 1, 2015
33453af
Merge remote-tracking branch 'origin/payload' into payload
Minishlink Dec 2, 2015
6f500fb
Merge branch 'master' into payload
Minishlink Dec 3, 2015
c6e2360
fix merge
Minishlink Dec 3, 2015
761a9d2
Merge remote-tracking branch 'origin/payload' into payload
Minishlink Dec 4, 2015
5fc6e04
Merge branch 'master' into payload
Minishlink Dec 4, 2015
238159e
Merge branch 'master' into payload
Minishlink Mar 7, 2016
8b142d7
composer require spomky-labs/jose
Minishlink Mar 9, 2016
0fc3364
fetch correct version of jose
Minishlink Mar 9, 2016
c87be97
test
Minishlink Mar 9, 2016
2da80d3
jose optional
Minishlink Mar 9, 2016
85f8b5f
don't fetch dev dependancies in travis
Minishlink Mar 9, 2016
91b7d2f
fix use statement if PHP version not supported
Minishlink Mar 9, 2016
6e1f8c0
trying to fix travis scripts
Minishlink Mar 9, 2016
d760ce8
fix travis?
Minishlink Mar 9, 2016
becdea3
fix travis?
Minishlink Mar 9, 2016
4597a3a
fix > PHP5.5.9 without jose
Minishlink Mar 9, 2016
0edf4d3
fix travis?
Minishlink Mar 9, 2016
8f88ed8
fix travis?
Minishlink Mar 9, 2016
4231029
fix travis?
Minishlink Mar 12, 2016
9dff24a
fix return data when there is an error with more than one notification
Minishlink Mar 12, 2016
02f5061
fix test encrypt()
Minishlink Mar 12, 2016
f6312ae
update headers to latest IETF draft and encode in URL safe base64
Minishlink Mar 16, 2016
34491d0
work on aesgcm
Minishlink Mar 16, 2016
4537e1d
change API to take into account user auth token
Minishlink Mar 19, 2016
88f8dbf
remove useless test of encrypt
Minishlink Mar 19, 2016
83ee6c9
make sure the record size is <= 4078 bytes
Minishlink Mar 19, 2016
3b32ed8
change content-encoding and added some comments
Minishlink Mar 19, 2016
423a05a
add automatic padding of the payload and ability to disable it for ba…
Minishlink Mar 19, 2016
f6aaef5
typo
Minishlink Mar 19, 2016
8d672e3
fix new API with user auth token in README
Minishlink Mar 19, 2016
5171604
disable automatic padding in tests to speed these up
Minishlink Mar 19, 2016
00e6985
update README with up-to-date info
Minishlink Mar 20, 2016
51f0f44
add GCM with payload test
Minishlink Mar 20, 2016
d709a60
use new standard and temp server for Chrome
Minishlink Mar 20, 2016
d2dbba0
typo
Minishlink Mar 20, 2016
314ba0c
use new version openssl_encrypt (>=PHP7.1)
Minishlink Mar 20, 2016
bd16be7
fix test wrong API key with new Chrome server
Minishlink Mar 20, 2016
a4bf323
User auth token must be provided if sending payload
Minishlink Mar 20, 2016
0395a2b
Special characters in test
Minishlink Mar 20, 2016
3e750c1
victory!
Minishlink Mar 20, 2016
8626cf5
In Travis, skip tests that send actual notifications because it's rea…
Minishlink Mar 20, 2016
0e21bba
clarifications
Minishlink Mar 20, 2016
06d9495
fix skip Travis and test with wrong API
Minishlink Mar 20, 2016
4838274
fix skip Travis tests?
Minishlink Mar 20, 2016
5e51681
prepend length of padding in the plaintext
Minishlink Mar 21, 2016
5048463
fix test automatic padding
Minishlink Mar 22, 2016
dda9aca
fix padding length
Minishlink Mar 22, 2016
5de72ea
change browser support version
Minishlink Apr 28, 2016
1ba1585
use spomky-labs/php-aes-gcm (thanks @spomky-labs)
Minishlink Apr 28, 2016
1831b8d
rm spomky-labs/jose from travis
Minishlink Apr 28, 2016
70de046
payload is supported on PHP5.4+
Minishlink Apr 28, 2016
006fdde
add travis PHP7
Minishlink Apr 28, 2016
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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