Skip to content

Commit dd9d225

Browse files
authored
Merge pull request #41 from web-push-libs/vapid
Add VAPID compatibility, close #8
2 parents dd1da44 + b21561b commit dd9d225

13 files changed

+513
-195
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ before_install:
1313
- nvm install node
1414

1515
install:
16-
- npm install web-push-testing-service@0.2.1 -g
16+
- npm install web-push-testing-service@0.3.0 -g
1717

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

CONTRIBUTING.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,4 @@ following commands:
2020
**For a Single Test**
2121
`php phpunit.phar . --filter "/::testPadPayload( .*)?$/"` (regex)
2222

23-
Some tests have a custom decorator @skipIfTravis. The reason is that
24-
there's no way in Travis to update the push subscription, so the endpoint
25-
in my phpunit.travis.xml would ultimately expire
26-
(and require a human modification), and the corresponding tests would fail.
2723
But locally, these tests are handy.

README.md

Lines changed: 21 additions & 6 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. 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.
7575

76-
You will need to specify your GCM api key when instantiating WebPush:
76+
You can specify your authentication details when instantiating WebPush. The keys can be passed directly, or you can load a PEM file or its content:
7777
```php
7878
<?php
7979

8080
use Minishlink\WebPush\WebPush;
8181

8282
$endpoint = 'https://android.googleapis.com/gcm/send/abcdef...'; // Chrome
83-
$apiKeys = array(
84-
'GCM' => 'MY_GCM_API_KEY',
83+
84+
$auth = array(
85+
'GCM' => 'MY_GCM_API_KEY', // deprecated and optional, it's here only for compatibility reasons
86+
'VAPID' => array(
87+
'subject' => 'mailto:[email protected]', // can be a mailto: or your website address
88+
'publicKey' => '~88 chars', // uncompressed public key P-256 encoded in Base64-URL
89+
'privateKey' => '~44 chars', // in fact the secret multiplier of the private key encoded in Base64-URL
90+
'pemFile' => 'path/to/pem', // if you have a PEM file and can link to it on your filesystem
91+
'pem' => 'pemFileContent', // if you have a PEM file and want to hardcode its content
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 uncompressed public and secret key, encoded in Base64, 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|tr -d '=' |tr '/+' '_-' >> public_key.txt
103+
$ openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> 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/Encryption.php

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,22 @@ final class Encryption
2121

2222
/**
2323
* @param string $payload
24-
* @param bool $automatic
24+
* @param bool $automatic
25+
*
2526
* @return string padded payload (plaintext)
2627
*/
2728
public static function padPayload($payload, $automatic)
2829
{
2930
$payloadLen = Utils::safeStrlen($payload);
3031
$padLen = $automatic ? self::MAX_PAYLOAD_LENGTH - $payloadLen : 0;
32+
3133
return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
3234
}
3335

3436
/**
35-
* @param string $payload With padding
36-
* @param string $userPublicKey Base 64 encoded (MIME or URL-safe)
37-
* @param string $userAuthToken Base 64 encoded (MIME or URL-safe)
37+
* @param string $payload With padding
38+
* @param string $userPublicKey Base 64 encoded (MIME or URL-safe)
39+
* @param string $userAuthToken Base 64 encoded (MIME or URL-safe)
3840
* @param bool $nativeEncryption Use OpenSSL (>PHP7.1)
3941
*
4042
* @return array
@@ -60,8 +62,7 @@ public static function encrypt($payload, $userPublicKey, $userAuthToken, $native
6062
$userPublicKeyObject = $generator->getPublicKeyFrom($pointUserPublicKey->getX(), $pointUserPublicKey->getY(), $generator->getOrder());
6163

6264
// get shared secret from user public key and local private key
63-
$localPrivateSecret = $localPrivateKeyObject->getSecret();
64-
$sharedSecret = hex2bin($math->decHex(gmp_strval($userPublicKeyObject->getPoint()->mul($localPrivateSecret)->getX())));
65+
$sharedSecret = hex2bin($math->decHex(gmp_strval($userPublicKeyObject->getPoint()->mul($localPrivateKeyObject->getSecret())->getX())));
6566

6667
// generate salt
6768
$salt = openssl_random_pseudo_bytes(16);
@@ -85,7 +86,7 @@ public static function encrypt($payload, $userPublicKey, $userAuthToken, $native
8586
// encrypt
8687
// "The additional data passed to each invocation of AEAD_AES_128_GCM is a zero-length octet sequence."
8788
if (!$nativeEncryption) {
88-
list($encryptedText, $tag) = \AESGCM\AESGCM::encrypt($contentEncryptionKey, $nonce, $payload, "");
89+
list($encryptedText, $tag) = \AESGCM\AESGCM::encrypt($contentEncryptionKey, $nonce, $payload, '');
8990
} else {
9091
$encryptedText = openssl_encrypt($payload, 'aes-128-gcm', $contentEncryptionKey, OPENSSL_RAW_DATA, $nonce, $tag); // base 64 encoded
9192
}
@@ -99,7 +100,7 @@ public static function encrypt($payload, $userPublicKey, $userAuthToken, $native
99100
}
100101

101102
/**
102-
* HMAC-based Extract-and-Expand Key Derivation Function (HKDF)
103+
* HMAC-based Extract-and-Expand Key Derivation Function (HKDF).
103104
*
104105
* This is used to derive a secure encryption key from a mostly-secure shared
105106
* secret.
@@ -115,6 +116,7 @@ public static function encrypt($payload, $userPublicKey, $userAuthToken, $native
115116
* @param $ikm string Input keying material
116117
* @param $info string Application-specific context
117118
* @param $length int The length (in bytes) of the required output key
119+
*
118120
* @return string
119121
*/
120122
private static function hkdf($salt, $ikm, $info, $length)
@@ -130,11 +132,13 @@ private static function hkdf($salt, $ikm, $info, $length)
130132
* Creates a context for deriving encyption parameters.
131133
* See section 4.2 of
132134
* {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
133-
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
135+
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
134136
*
135137
* @param $clientPublicKey string The client's public key
136138
* @param $serverPublicKey string Our public key
139+
*
137140
* @return string
141+
*
138142
* @throws \ErrorException
139143
*/
140144
private static function createContext($clientPublicKey, $serverPublicKey)
@@ -156,14 +160,17 @@ private static function createContext($clientPublicKey, $serverPublicKey)
156160
/**
157161
* Returns an info record. See sections 3.2 and 3.3 of
158162
* {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
159-
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
163+
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
160164
*
161165
* @param $type string The type of the info record
162166
* @param $context string The context for the record
167+
*
163168
* @return string
169+
*
164170
* @throws \ErrorException
165171
*/
166-
private static function createInfo($type, $context) {
172+
private static function createInfo($type, $context)
173+
{
167174
if (Utils::safeStrlen($context) !== 135) {
168175
throw new \ErrorException('Context argument has invalid size');
169176
}

src/Notification.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class Notification
2525
/** @var string */
2626
private $userAuthToken;
2727

28-
/** @var array Options : TTL, urgency, topic **/
28+
/** @var array Options : TTL, urgency, topic * */
2929
private $options;
3030

3131
public function __construct($endpoint, $payload, $userPublicKey, $userAuthToken, $options)
@@ -71,16 +71,16 @@ public function getUserAuthToken()
7171

7272
/**
7373
* @param array $defaultOptions
74+
*
7475
* @return array
7576
*/
7677
public function getOptions(array $defaultOptions = array())
7778
{
7879
$options = $this->options;
79-
$options['TTL'] = array_key_exists('TTL', $options) ? $options['TTL'] : $defaultOptions['TTL'];
80+
$options['TTL'] = array_key_exists('TTL', $options) ? $options['TTL'] : $defaultOptions['TTL'];
8081
$options['urgency'] = array_key_exists('urgency', $options) ? $options['urgency'] : $defaultOptions['urgency'];
8182
$options['topic'] = array_key_exists('topic', $options) ? $options['topic'] : $defaultOptions['topic'];
8283

8384
return $options;
8485
}
85-
8686
}

src/Utils.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
class Utils
1515
{
16-
public static function safeStrlen($string) {
17-
return mb_strlen($string, "8bit");
16+
public static function safeStrlen($string)
17+
{
18+
return mb_strlen($string, '8bit');
1819
}
1920
}

src/VAPID.php

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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 Mdanter\Ecc\EccFactory;
18+
use Mdanter\Ecc\Serializer\Point\UncompressedPointSerializer;
19+
use Mdanter\Ecc\Serializer\PrivateKey\DerPrivateKeySerializer;
20+
use Mdanter\Ecc\Serializer\PrivateKey\PemPrivateKeySerializer;
21+
22+
class VAPID
23+
{
24+
/**
25+
* @param array $vapid
26+
*
27+
* @return array
28+
*
29+
* @throws \ErrorException
30+
*/
31+
public static function validate(array $vapid)
32+
{
33+
if (!array_key_exists('subject', $vapid)) {
34+
throw new \ErrorException('[VAPID] You must provide a subject that is either a mailto: or a URL.');
35+
}
36+
37+
if (array_key_exists('pemFile', $vapid)) {
38+
$vapid['pem'] = file_get_contents($vapid['pemFile']);
39+
40+
if (!$vapid['pem']) {
41+
throw new \ErrorException('Error loading PEM file.');
42+
}
43+
}
44+
45+
if (array_key_exists('pem', $vapid)) {
46+
$pem = $vapid['pem'];
47+
$posStartKey = strpos($pem, '-----BEGIN EC PRIVATE KEY-----');
48+
$posEndKey = strpos($pem, '-----END EC PRIVATE KEY-----');
49+
50+
if ($posStartKey === false || $posEndKey === false) {
51+
throw new \ErrorException('Invalid PEM data.');
52+
}
53+
54+
$posStartKey += 30; // length of '-----BEGIN EC PRIVATE KEY-----'
55+
56+
$pemSerializer = new PemPrivateKeySerializer(new DerPrivateKeySerializer());
57+
$keys = $pemSerializer->parse(substr($pem, $posStartKey, $posEndKey - $posStartKey));
58+
59+
$pointSerializer = new UncompressedPointSerializer(EccFactory::getAdapter());
60+
$vapid['publicKey'] = base64_encode(hex2bin($pointSerializer->serialize($keys->getPublicKey()->getPoint())));
61+
$vapid['privateKey'] = base64_encode(hex2bin(gmp_strval($keys->getSecret(), 16)));
62+
}
63+
64+
if (!array_key_exists('publicKey', $vapid)) {
65+
throw new \ErrorException('[VAPID] You must provide a public key.');
66+
}
67+
68+
$publicKey = Base64Url::decode($vapid['publicKey']);
69+
70+
if (Utils::safeStrlen($publicKey) !== 65) {
71+
throw new \ErrorException('[VAPID] Public key should be 65 bytes long when decoded.');
72+
}
73+
74+
if (!array_key_exists('privateKey', $vapid)) {
75+
throw new \ErrorException('[VAPID] You must provide a private key.');
76+
}
77+
78+
$privateKey = Base64Url::decode($vapid['privateKey']);
79+
80+
if (Utils::safeStrlen($privateKey) !== 32) {
81+
throw new \ErrorException('[VAPID] Private key should be 32 bytes long when decoded.');
82+
}
83+
84+
return array(
85+
'subject' => $vapid['subject'],
86+
'publicKey' => $publicKey,
87+
'privateKey' => $privateKey,
88+
);
89+
}
90+
91+
/**
92+
* This method takes the required VAPID parameters and returns the required
93+
* header to be added to a Web Push Protocol Request.
94+
*
95+
* @param string $audience This must be the origin of the push service
96+
* @param string $subject This should be a URL or a 'mailto:' email address
97+
* @param string $publicKey The decoded VAPID public key
98+
* @param string $privateKey The decoded VAPID private key
99+
* @param int $expiration The expiration of the VAPID JWT. (UNIX timestamp)
100+
*
101+
* @return array Returns an array with the 'Authorization' and 'Crypto-Key' values to be used as headers
102+
*/
103+
public static function getVapidHeaders($audience, $subject, $publicKey, $privateKey, $expiration = null)
104+
{
105+
$expirationLimit = time() + 86400;
106+
if (!isset($expiration) || $expiration > $expirationLimit) {
107+
$expiration = $expirationLimit;
108+
}
109+
110+
$header = array(
111+
'typ' => 'JWT',
112+
'alg' => 'ES256',
113+
);
114+
115+
$jwtPayload = json_encode(array(
116+
'aud' => $audience,
117+
'exp' => $expiration,
118+
'sub' => $subject,
119+
), JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK);
120+
121+
$generator = EccFactory::getNistCurves()->generator256();
122+
$privateKeyObject = $generator->getPrivateKeyFrom(gmp_init(bin2hex($privateKey), 16));
123+
$pemSerialize = new PemPrivateKeySerializer(new DerPrivateKeySerializer());
124+
$pem = $pemSerialize->serialize($privateKeyObject);
125+
126+
$jwk = JWKFactory::createFromKey($pem, null);
127+
$jws = JWSFactory::createJWSToCompactJSON($jwtPayload, $jwk, $header);
128+
129+
return array(
130+
'Authorization' => 'WebPush '.$jws,
131+
'Crypto-Key' => 'p256ecdsa='.Base64Url::encode($publicKey),
132+
);
133+
}
134+
}

0 commit comments

Comments
 (0)