Skip to content

Commit 7d3a089

Browse files
committed
add VAPID (not working yet)
1 parent 682fc8a commit 7d3a089

File tree

6 files changed

+183
-22
lines changed

6 files changed

+183
-22
lines changed

README.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,24 +70,39 @@ There are several good examples and tutorials on the web:
7070
* 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)
7171
* you may want to take a look at my own implementation: [sw.js](https://github.com/Minishlink/physbook/blob/2ed8b9a8a217446c9747e9191a50d6312651125d/web/service-worker.js) and [app.js](https://github.com/Minishlink/physbook/blob/d6855ca8f485556ab2ee5c047688fbf745367045/app/Resources/public/js/app.js)
7272

73-
### GCM servers notes (Chrome)
74-
For compatibility reasons, this library detects if the server is a GCM server and appropriately sends the notification.
73+
### Authentication
74+
Browsers need to verify your identity. At the moment, some browsers don't force you but you'll have to do it in the future, so why not now?
75+
GCM (Chrome, Opera, Samsung Mobile) do force you to authenticate using an API key that you can find either on your Google Developer Console or Firebase Console.
76+
A standard called VAPID can authenticate you for all browsers. You'll need to create and provide a public and private key for your server.
7577

76-
You will need to specify your GCM api key when instantiating WebPush:
78+
You can specify your authentication details when instantiating WebPush:
7779
```php
7880
<?php
7981

8082
use Minishlink\WebPush\WebPush;
8183

8284
$endpoint = 'https://android.googleapis.com/gcm/send/abcdef...'; // Chrome
83-
$apiKeys = array(
85+
86+
$auth = array(
8487
'GCM' => 'MY_GCM_API_KEY',
88+
'VAPID' => array(
89+
'subject' => 'mailto:[email protected]', // can be a mailto: or your website address
90+
'publicKey' => '88 chars', // uncompressed public key P-256
91+
'privateKey' => '44 chars', // in fact the secret multiplier of the private key
92+
),
8593
);
8694

87-
$webPush = new WebPush($apiKeys);
95+
$webPush = new WebPush($auth);
8896
$webPush->sendNotification($endpoint, null, null, null, true);
8997
```
9098

99+
In order to generate the public and private keys, enter the following in your Linux bash:
100+
```
101+
$ openssl ecparam -genkey -name prime256v1 -out private_key.pem
102+
$ openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64 >> public_key.txt
103+
$ openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64 >> private_key.txt
104+
```
105+
91106
### Notification options
92107
Each notification can have a specific Time To Live, urgency, and topic.
93108
You can change the default options with `setDefaultOptions()` or in the constructor:

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"mdanter/ecc": "^0.4.0",
1919
"lib-openssl": "*",
2020
"spomky-labs/base64url": "^1.0",
21-
"spomky-labs/php-aes-gcm": "^1.0"
21+
"spomky-labs/php-aes-gcm": "^1.0",
22+
"spomky-labs/jose": "^6.0"
2223
},
2324
"require-dev": {
2425
"phpunit/phpunit": "4.8.*"

phpunit.dist.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,8 @@
2323
<env name="GCM_USER_PUBLIC_KEY" value="" />
2424
<env name="GCM_USER_AUTH_TOKEN" value="" />
2525
<env name="GCM_API_KEY" value="" />
26+
27+
<env name="VAPID_PUBLIC_KEY" value="" />
28+
<env name="VAPID_PRIVATE_KEY" value="" />
2629
</php>
2730
</phpunit>

src/VAPID.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 Jose\Factory\JWKFactory;
16+
use Jose\Factory\JWSFactory;
17+
use Jose\Object\JWK;
18+
use Mdanter\Ecc\Curves\NistCurve;
19+
use Mdanter\Ecc\EccFactory;
20+
use Mdanter\Ecc\Serializer\PrivateKey\DerPrivateKeySerializer;
21+
use Mdanter\Ecc\Serializer\PrivateKey\PemPrivateKeySerializer;
22+
23+
class VAPID
24+
{
25+
/**
26+
* @param array $vapid
27+
*
28+
* @return array
29+
*
30+
* @throws \ErrorException
31+
*/
32+
public static function validate(array $vapid)
33+
{
34+
if (!array_key_exists('subject', $vapid)) {
35+
throw new \ErrorException('[VAPID] You must provide a subject that is either a mailto: or a URL.');
36+
}
37+
38+
if (!array_key_exists('publicKey', $vapid)) {
39+
throw new \ErrorException('[VAPID] You must provide a public key.');
40+
}
41+
42+
$publicKey = Base64Url::decode($vapid['publicKey']);
43+
44+
if (Utils::safe_strlen($publicKey) !== 65) {
45+
throw new \ErrorException('[VAPID] Public key should be 65 bytes long when decoded.');
46+
}
47+
48+
if (!array_key_exists('privateKey', $vapid)) {
49+
throw new \ErrorException('[VAPID] You must provide a private key.');
50+
}
51+
52+
$privateKey = Base64Url::decode($vapid['privateKey']);
53+
54+
if (Utils::safe_strlen($privateKey) !== 32) {
55+
throw new \ErrorException('[VAPID] Private key should be 32 bytes long when decoded.');
56+
}
57+
58+
return array(
59+
'subject' => $vapid['subject'],
60+
'publicKey' => $publicKey,
61+
'privateKey' => $privateKey,
62+
);
63+
}
64+
65+
/**
66+
* This method takes the required VAPID parameters and returns the required
67+
* header to be added to a Web Push Protocol Request.
68+
*
69+
* @param string $audience This must be the origin of the push service
70+
* @param string $subject This should be a URL or a 'mailto:' email address
71+
* @param string $publicKey The decoded VAPID public key
72+
* @param string $privateKey The decoded VAPID private key
73+
* @param int $expiration The expiration of the VAPID JWT. (UNIX timestamp)
74+
*
75+
* @return array Returns an array with the 'Authorization' and 'Crypto-Key' values to be used as headers
76+
*/
77+
public static function getVapidHeaders($audience, $subject, $publicKey, $privateKey, $expiration = null)
78+
{
79+
$expirationLimit = time() + 86400;
80+
if (!isset($expiration) || $expiration > $expirationLimit) {
81+
$expiration = $expirationLimit;
82+
}
83+
84+
$header = array(
85+
'typ' => 'JWT',
86+
'alg' => 'ES256',
87+
);
88+
89+
$jwtPayload = array(
90+
'aud' => $audience,
91+
'exp' => $expiration,
92+
'sub' => $subject,
93+
);
94+
95+
$generator = EccFactory::getNistCurves()->generator256();
96+
$privateKeyObject = $generator->getPrivateKeyFrom(gmp_init(bin2hex($privateKey), 16));
97+
$pemSerialize = new PemPrivateKeySerializer(new DerPrivateKeySerializer());
98+
$pem = $pemSerialize->serialize($privateKeyObject);
99+
$jwk = JWKFactory::createFromKey($pem, null);
100+
$jws = JWSFactory::createJWSToCompactJSON($jwtPayload, $jwk, $header);
101+
102+
return array(
103+
'Authorization' => 'WebPush '.$jws,
104+
'Crypto-Key' => 'p256ecdsa='.Base64Url::encode($publicKey),
105+
);
106+
}
107+
}

src/WebPush.php

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,45 +37,52 @@ class WebPush
3737
/** @var bool Automatic padding of payloads, if disabled, trade security for bandwidth */
3838
private $automaticPadding = true;
3939

40-
/** @var boolean */
40+
/** @var bool */
4141
private $nativePayloadEncryptionSupport;
4242

4343
/**
4444
* WebPush constructor.
4545
*
46-
* @param array $auth Some servers needs authentication.
47-
* @param array $defaultOptions TTL, urgency, topic
48-
* @param int|null $timeout Timeout of POST request
46+
* @param array $auth Some servers needs authentication
47+
* @param array $defaultOptions TTL, urgency, topic
48+
* @param int|null $timeout Timeout of POST request
4949
* @param AbstractClient|null $client
5050
*/
5151
public function __construct(array $auth = array(), $defaultOptions = array(), $timeout = 30, AbstractClient $client = null)
5252
{
5353
$this->auth = $auth;
54+
55+
if (array_key_exists('VAPID', $auth)) {
56+
$auth['VAPID'] = VAPID::validate($auth['VAPID']);
57+
}
58+
5459
$this->setDefaultOptions($defaultOptions);
5560

5661
$client = isset($client) ? $client : new MultiCurl();
5762
$client->setTimeout($timeout);
5863
$this->browser = new Browser($client);
59-
64+
6065
$this->nativePayloadEncryptionSupport = version_compare(phpversion(), '7.1', '>=');
6166
}
6267

6368
/**
6469
* Send a notification.
6570
*
66-
* @param string $endpoint
67-
* @param string|null $payload If you want to send an array, json_encode it.
71+
* @param string $endpoint
72+
* @param string|null $payload If you want to send an array, json_encode it
6873
* @param string|null $userPublicKey
6974
* @param string|null $userAuthToken
70-
* @param bool $flush If you want to flush directly (usually when you send only one notification)
71-
* @param array $options Array with several options tied to this notification. If not set, will use the default options that you can set in the WebPush object.
75+
* @param bool $flush If you want to flush directly (usually when you send only one notification)
76+
* @param array $options Array with several options tied to this notification. If not set, will use the default options that you can set in the WebPush object
77+
*
7278
* @return array|bool Return an array of information if $flush is set to true and the queued requests has failed.
73-
* Else return true.
79+
* Else return true
80+
*
7481
* @throws \ErrorException
7582
*/
7683
public function sendNotification($endpoint, $payload = null, $userPublicKey = null, $userAuthToken = null, $flush = false, $options = array())
7784
{
78-
if(isset($payload)) {
85+
if (isset($payload)) {
7986
if (Utils::safe_strlen($payload) > Encryption::MAX_PAYLOAD_LENGTH) {
8087
throw new \ErrorException('Size of payload must not be greater than '.Encryption::MAX_PAYLOAD_LENGTH.' octets.');
8188
}
@@ -109,7 +116,7 @@ public function sendNotification($endpoint, $payload = null, $userPublicKey = nu
109116
*
110117
* @return array|bool If there are no errors, return true.
111118
* If there were no notifications in the queue, return false.
112-
* Else return an array of information for each notification sent (success, statusCode, headers, content).
119+
* Else return an array of information for each notification sent (success, statusCode, headers, content)
113120
*
114121
* @throws \ErrorException
115122
*/
@@ -214,6 +221,27 @@ private function prepareAndSend(array $notifications)
214221
throw new \ErrorException('No GCM/FCM API Key specified.');
215222
}
216223
}
224+
// if VAPID
225+
elseif (array_key_exists('VAPID', $this->auth)) {
226+
$vapid = $this->auth['VAPID'];
227+
228+
$audience = parse_url($endpoint, PHP_URL_SCHEME).'//'.parse_url($endpoint, PHP_URL_HOST);
229+
230+
if (!parse_url($audience)) {
231+
throw new \ErrorException('Audience "'.$audience.'"" could not be generated.');
232+
}
233+
234+
$vapidHeaders = VAPID::getVapidHeaders($audience, $vapid['subject'], $vapid['publicKey'], $vapid['privateKey']);
235+
236+
$headers['Authorization'] = 'key='.$vapidHeaders['Authorization'];
237+
238+
if (array_key_exists('Crypto-Key', $headers)) {
239+
// FUTURE replace ';' with ','
240+
$headers['Crypto-Key'] .= ';'.$vapidHeaders['Crypto-Key'];
241+
} else {
242+
$headers['Crypto-Key'] = $vapidHeaders['Crypto-Key'];
243+
}
244+
}
217245

218246
$responses[] = $this->sendRequest($endpoint, $headers, $content);
219247
}
@@ -260,15 +288,15 @@ public function setBrowser($browser)
260288
}
261289

262290
/**
263-
* @return boolean
291+
* @return bool
264292
*/
265293
public function isAutomaticPadding()
266294
{
267295
return $this->automaticPadding;
268296
}
269297

270298
/**
271-
* @param boolean $automaticPadding
299+
* @param bool $automaticPadding
272300
*
273301
* @return WebPush
274302
*/

tests/WebPushTest.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ protected function checkRequirements()
3232
$this->markTestSkipped('This test does not run on Travis.');
3333
}
3434
}
35-
35+
3636
public static function setUpBeforeClass()
3737
{
3838
self::$endpoints = array(
@@ -53,7 +53,14 @@ public static function setUpBeforeClass()
5353

5454
public function setUp()
5555
{
56-
$this->webPush = new WebPush(array('GCM' => getenv('GCM_API_KEY')));
56+
$this->webPush = new WebPush(array(
57+
'GCM' => getenv('GCM_API_KEY'),
58+
'VAPID' => array(
59+
'subject' => 'https://github.com/Minishlink/web-push',
60+
'publicKey' => getenv('VAPID_PUBLIC_KEY'),
61+
'privateKey' => getenv('VAPID_PRIVATE_KEY'),
62+
),
63+
));
5764
$this->webPush->setAutomaticPadding(false); // disable automatic padding in tests to speed these up
5865
}
5966

0 commit comments

Comments
 (0)