Skip to content

Commit 96bf635

Browse files
committed
Merge pull request #1 from Minishlink/payload
Payload support with Firefox 46+ and Chrome 50+
2 parents eb5902d + 006fdde commit 96bf635

9 files changed

+519
-109
lines changed

.travis.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ php:
55
- 5.5
66
- 5.6
77
- hhvm
8+
- 7.0
89

910
before_script:
10-
- composer install --prefer-source -n
11+
- composer install --prefer-source -n --no-dev
1112

1213
script: phpunit -c phpunit.travis.xml

README.md

+71-21
Original file line numberDiff line numberDiff line change
@@ -9,45 +9,67 @@
99

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

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

2217
```php
2318
<?php
2419

2520
use Minishlink\WebPush\WebPush;
2621

27-
// array of endpoints
28-
$endpoints = array(
29-
'https://android.googleapis.com/gcm/send/abcdef...', // Chrome
30-
'https://updates.push.services.mozilla.com/push/adcdef...', // Firefox 43+
31-
'https://example.com/other/endpoint/of/another/vendor/abcdef...',
22+
// array of notifications
23+
$notifications = array(
24+
array(
25+
'endpoint' => 'https://updates.push.services.mozilla.com/push/abc...', // Firefox 43+
26+
'payload' => 'hello !',
27+
'userPublicKey' => 'BPcMbnWQL5GOYX/5LKZXT6sLmHiMsJSiEvIFvfcDvX7IZ9qqtq68onpTPEYmyxSQNiH7UD/98AUcQ12kBoxz/0s=', // base 64 encoded, should be 88 chars
28+
'userAuthToken' => 'CxVX6QsVToEGEcjfYPqXQw==', // base 64 encoded, should be 24 chars
29+
), array(
30+
'endpoint' => 'https://android.googleapis.com/gcm/send/abcdef...', // Chrome
31+
'payload' => null,
32+
'userPublicKey' => null,
33+
'userAuthToken' => null,
34+
), array(
35+
'endpoint' => 'https://example.com/other/endpoint/of/another/vendor/abcdef...',
36+
'payload' => '{msg:"test"}',
37+
'userPublicKey' => '(stringOf88Chars)',
38+
'userAuthToken' => '(stringOf24Chars)',
39+
),
3240
);
3341

3442
$webPush = new WebPush();
3543

36-
// send multiple notifications
37-
foreach ($endpoints as $endpoint) {
38-
$webPush->sendNotification($endpoint);
44+
// send multiple notifications with payload
45+
foreach ($notifications as $notification) {
46+
$webPush->sendNotification(
47+
$notification['endpoint'],
48+
$notification['payload'], // optional (defaults null)
49+
$notification['userPublicKey'], // optional (defaults null)
50+
$notification['userAuthToken'] // optional (defaults null)
51+
);
3952
}
4053
$webPush->flush();
4154

4255
// send one notification and flush directly
43-
$webPush->sendNotification($endpoints[0], null, null, true);
56+
$webPush->sendNotification(
57+
$notifications[0]['endpoint'],
58+
$notifications[0]['payload'], // optional (defaults null)
59+
$notifications[0]['userPublicKey'], // optional (defaults null)
60+
$notifications[0]['userAuthToken'], // optional (defaults null)
61+
true // optional (defaults false)
62+
);
4463
```
4564

65+
### Client side implementation of Web Push
66+
There are several good examples and tutorials on the web:
67+
* 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)
68+
* 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)
69+
* 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)
70+
4671
### GCM servers notes (Chrome)
4772
For compatibility reasons, this library detects if the server is a GCM server and appropriately sends the notification.
48-
GCM servers don't support encrypted payloads yet so WebPush will skip the payload.
49-
If you still want to show that payload on your notification, you should get that data on client-side from your server
50-
where you will have to store somewhere the history of notifications.
5173

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

6385
$webPush = new WebPush($apiKeys);
64-
$webPush->sendNotification($endpoint, null, null, true);
86+
$webPush->sendNotification($endpoint, null, null, null, true);
87+
```
88+
89+
### Payload length and security
90+
Payload will be encrypted by the library. The maximum payload length is 4078 bytes (or ASCII characters).
91+
92+
However, when you encrypt a string of a certain length, the resulting string will always have the same length,
93+
no matter how many times you encrypt the initial string. This can make attackers guess the content of the payload.
94+
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
95+
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
96+
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.
97+
That's why the library provides the option to disable this security measure:
98+
99+
```php
100+
<?php
101+
102+
use Minishlink\WebPush\WebPush;
103+
104+
$webPush = new WebPush();
105+
$webPush->setAutomaticPadding(false); // disable automatic padding
65106
```
66107

67108
### Time To Live
68109
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
69110
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.
70111
However, if you send multiple nonessential notifications, set a TTL of 0: the push notification will be delivered only
71112
if the user is currently connected. For other cases, you should use a minimum of one day if your users have multiple time
72-
zones, and if you don't several hours will suffice.
113+
zones, and if they don't several hours will suffice.
73114

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

169+
### What about security?
170+
Payload is encrypted according to the [Message Encryption for Web Push](https://tools.ietf.org/html/draft-ietf-webpush-encryption-01) standard,
171+
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.
172+
173+
Internally, WebPush uses the [phpecc](https://github.com/phpecc/phpecc) Elliptic Curve Cryptography library to create
174+
local public and private keys and compute the shared secret.
175+
Then, if you have a PHP >= 7.1, WebPush uses `openssl` in order to encrypt the payload with the encryption key.
176+
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.
177+
128178
### How to solve "SSL certificate problem: unable to get local issuer certificate" ?
129179
Your installation lacks some certificates.
130180

composer.json

+6-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "minishlink/web-push",
33
"type": "library",
44
"description": "Web Push library for PHP",
5-
"keywords": ["push", "notifications", "web"],
5+
"keywords": ["push", "notifications", "web", "WebPush", "Push API"],
66
"homepage": "https://github.com/Minishlink/web-push",
77
"license": "MIT",
88
"authors": [
@@ -14,7 +14,11 @@
1414
],
1515
"require": {
1616
"php": ">=5.4",
17-
"kriswallsmith/buzz": ">=0.6"
17+
"kriswallsmith/buzz": ">=0.6",
18+
"mdanter/ecc": "^0.3.0",
19+
"lib-openssl": "*",
20+
"spomky-labs/base64url": "^1.0",
21+
"spomky-labs/php-aes-gcm": "^1.0"
1822
},
1923
"require-dev": {
2024
"phpunit/phpunit": "4.8.*"

phpunit.dist.xml

+5-1
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
</testsuites>
1717
<php>
1818
<env name="STANDARD_ENDPOINT" value="" />
19-
<env name="GCM_ENDPOINT" value="" />
2019
<env name="USER_PUBLIC_KEY" value="" />
20+
<env name="USER_AUTH_TOKEN" value="" />
21+
22+
<env name="GCM_ENDPOINT" value="" />
23+
<env name="GCM_USER_PUBLIC_KEY" value="" />
24+
<env name="GCM_USER_AUTH_TOKEN" value="" />
2125
<env name="GCM_API_KEY" value="" />
2226
</php>
2327
</phpunit>

src/Encryption.php

+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the WebPush library.
5+
*
6+
* (c) Louis Lagrange <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Minishlink\WebPush;
13+
14+
use Base64Url\Base64Url;
15+
use Mdanter\Ecc\EccFactory;
16+
use Mdanter\Ecc\Serializer\Point\UncompressedPointSerializer;
17+
18+
final class Encryption
19+
{
20+
const MAX_PAYLOAD_LENGTH = 4078;
21+
22+
/**
23+
* @param string $payload
24+
* @param bool $automatic
25+
* @return string padded payload (plaintext)
26+
*/
27+
public static function padPayload($payload, $automatic)
28+
{
29+
$payloadLen = strlen($payload);
30+
$padLen = $automatic ? self::MAX_PAYLOAD_LENGTH - $payloadLen : 0;
31+
return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
32+
}
33+
34+
/**
35+
* @param string $payload With padding
36+
* @param string $userPublicKey MIME base 64 encoded
37+
* @param string $userAuthToken MIME base 64 encoded
38+
* @param bool $nativeEncryption Use OpenSSL (>PHP7.1)
39+
*
40+
* @return array
41+
*/
42+
public static function encrypt($payload, $userPublicKey, $userAuthToken, $nativeEncryption)
43+
{
44+
$userPublicKey = base64_decode($userPublicKey);
45+
$userAuthToken = base64_decode($userAuthToken);
46+
47+
// initialize utilities
48+
$math = EccFactory::getAdapter();
49+
$pointSerializer = new UncompressedPointSerializer($math);
50+
$generator = EccFactory::getNistCurves()->generator256();
51+
$curve = EccFactory::getNistCurves()->curve256();
52+
53+
// get local key pair
54+
$localPrivateKeyObject = $generator->createPrivateKey();
55+
$localPublicKeyObject = $localPrivateKeyObject->getPublicKey();
56+
$localPublicKey = hex2bin($pointSerializer->serialize($localPublicKeyObject->getPoint()));
57+
58+
// get user public key object
59+
$pointUserPublicKey = $pointSerializer->unserialize($curve, bin2hex($userPublicKey));
60+
$userPublicKeyObject = $generator->getPublicKeyFrom($pointUserPublicKey->getX(), $pointUserPublicKey->getY(), $generator->getOrder());
61+
62+
// get shared secret from user public key and local private key
63+
$sharedSecret = hex2bin($math->decHex($userPublicKeyObject->getPoint()->mul($localPrivateKeyObject->getSecret())->getX()));
64+
65+
// generate salt
66+
$salt = openssl_random_pseudo_bytes(16);
67+
68+
// section 4.3
69+
$ikm = !empty($userAuthToken) ?
70+
self::hkdf($userAuthToken, $sharedSecret, 'Content-Encoding: auth'.chr(0), 32) :
71+
$sharedSecret;
72+
73+
// section 4.2
74+
$context = self::createContext($userPublicKey, $localPublicKey);
75+
76+
// derive the Content Encryption Key
77+
$contentEncryptionKeyInfo = self::createInfo('aesgcm', $context);
78+
$contentEncryptionKey = self::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16);
79+
80+
// section 3.3, derive the nonce
81+
$nonceInfo = self::createInfo('nonce', $context);
82+
$nonce = self::hkdf($salt, $ikm, $nonceInfo, 12);
83+
84+
// encrypt
85+
// "The additional data passed to each invocation of AEAD_AES_128_GCM is a zero-length octet sequence."
86+
if (!$nativeEncryption) {
87+
list($encryptedText, $tag) = \AESGCM\AESGCM::encrypt($contentEncryptionKey, $nonce, $payload, "");
88+
} else {
89+
$encryptedText = openssl_encrypt($payload, 'aes-128-gcm', $contentEncryptionKey, OPENSSL_RAW_DATA, $nonce, $tag); // base 64 encoded
90+
}
91+
92+
// return values in url safe base64
93+
return array(
94+
'localPublicKey' => Base64Url::encode($localPublicKey),
95+
'salt' => Base64Url::encode($salt),
96+
'cipherText' => $encryptedText.$tag,
97+
);
98+
}
99+
100+
/**
101+
* HMAC-based Extract-and-Expand Key Derivation Function (HKDF)
102+
*
103+
* This is used to derive a secure encryption key from a mostly-secure shared
104+
* secret.
105+
*
106+
* This is a partial implementation of HKDF tailored to our specific purposes.
107+
* In particular, for us the value of N will always be 1, and thus T always
108+
* equals HMAC-Hash(PRK, info | 0x01).
109+
*
110+
* See {@link https://www.rfc-editor.org/rfc/rfc5869.txt}
111+
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
112+
*
113+
* @param $salt string A non-secret random value
114+
* @param $ikm string Input keying material
115+
* @param $info string Application-specific context
116+
* @param $length int The length (in bytes) of the required output key
117+
* @return string
118+
*/
119+
private static function hkdf($salt, $ikm, $info, $length)
120+
{
121+
// extract
122+
$prk = hash_hmac('sha256', $ikm, $salt, true);
123+
124+
// expand
125+
return substr(hash_hmac('sha256', $info.chr(1), $prk, true), 0, $length);
126+
}
127+
128+
/**
129+
* Creates a context for deriving encyption parameters.
130+
* See section 4.2 of
131+
* {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
132+
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
133+
*
134+
* @param $clientPublicKey string The client's public key
135+
* @param $serverPublicKey string Our public key
136+
* @return string
137+
* @throws \ErrorException
138+
*/
139+
private static function createContext($clientPublicKey, $serverPublicKey)
140+
{
141+
if (strlen($clientPublicKey) !== 65) {
142+
throw new \ErrorException('Invalid client public key length');
143+
}
144+
145+
// This one should never happen, because it's our code that generates the key
146+
if (strlen($serverPublicKey) !== 65) {
147+
throw new \ErrorException('Invalid server public key length');
148+
}
149+
150+
$len = chr(0).'A'; // 65 as Uint16BE
151+
152+
return chr(0).$len.$clientPublicKey.$len.$serverPublicKey;
153+
}
154+
155+
/**
156+
* Returns an info record. See sections 3.2 and 3.3 of
157+
* {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
158+
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
159+
*
160+
* @param $type string The type of the info record
161+
* @param $context string The context for the record
162+
* @return string
163+
* @throws \ErrorException
164+
*/
165+
private static function createInfo($type, $context) {
166+
if (strlen($context) !== 135) {
167+
throw new \ErrorException('Context argument has invalid size');
168+
}
169+
170+
return 'Content-Encoding: '.$type.chr(0).'P-256'.$context;
171+
}
172+
}

0 commit comments

Comments
 (0)