Skip to content

Commit 75d198b

Browse files
authored
Add support for aes128gcm encoding (#150)
Fixes #48 #136 * Introduce Subscription object * Fix CI * Add content encoding field * Actually prevent using a non supported content encoding * Add PHPDoc tag * Add content encoding to VAPID * Add aes128gcm content encoding to VAPID * Only send Encryption and Crypto-Key headers in aesgcm * Add encryption logic of aes128gcm * Add aes128gcm to supported content encodings * Add encryptionContentCodingHeader * Add test for every supported content encodings * Use latest version of web-push-testing-service * Fix test when browser doesn't have PushManager.supportedContentEncodings * Fix pack ? * Replace fcm/send by wp for FCM urls * Add support for no padding in aes128gcm * Fix record size for aes128gcm * Fix padding with aes128gcm * Update README
1 parent 18ddf3a commit 75d198b

11 files changed

+474
-179
lines changed

.travis.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ before_install:
2525
- nvm install node
2626

2727
install:
28-
- npm install web-push-testing-service -g
28+
- npm install github:GoogleChromeLabs/web-push-testing-service -g
2929

3030
before_script:
3131
- composer install --prefer-source -n

README.md

+20-18
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,30 @@ Use [composer](https://getcomposer.org/) to download and install the library and
3232
<?php
3333

3434
use Minishlink\WebPush\WebPush;
35+
use Minishlink\WebPush\Subscription;
3536

3637
// array of notifications
3738
$notifications = [
3839
[
39-
'endpoint' => 'https://updates.push.services.mozilla.com/push/abc...', // Firefox 43+
40+
'subscription' => Subscription::create([
41+
'endpoint' => 'https://updates.push.services.mozilla.com/push/abc...', // Firefox 43+,
42+
'publicKey' => 'BPcMbnWQL5GOYX/5LKZXT6sLmHiMsJSiEvIFvfcDvX7IZ9qqtq68onpTPEYmyxSQNiH7UD/98AUcQ12kBoxz/0s=', // base 64 encoded, should be 88 chars
43+
'authToken' => 'CxVX6QsVToEGEcjfYPqXQw==', // base 64 encoded, should be 24 chars
44+
]),
4045
'payload' => 'hello !',
41-
'userPublicKey' => 'BPcMbnWQL5GOYX/5LKZXT6sLmHiMsJSiEvIFvfcDvX7IZ9qqtq68onpTPEYmyxSQNiH7UD/98AUcQ12kBoxz/0s=', // base 64 encoded, should be 88 chars
42-
'userAuthToken' => 'CxVX6QsVToEGEcjfYPqXQw==', // base 64 encoded, should be 24 chars
4346
], [
44-
'endpoint' => 'https://android.googleapis.com/gcm/send/abcdef...', // Chrome
47+
'subscription' => Subscription::create([
48+
'endpoint' => 'https://android.googleapis.com/gcm/send/abcdef...', // Chrome
49+
]),
4550
'payload' => null,
46-
'userPublicKey' => null,
47-
'userAuthToken' => null,
4851
], [
49-
'endpoint' => 'https://example.com/other/endpoint/of/another/vendor/abcdef...',
52+
'subscription' => Subscription::create([
53+
'endpoint' => 'https://example.com/other/endpoint/of/another/vendor/abcdef...',
54+
'publicKey' => '(stringOf88Chars)',
55+
'authToken' => '(stringOf24Chars)',
56+
'contentEncoding' => 'aesgcm', // one of PushManager.supportedContentEncodings
57+
]),
5058
'payload' => '{msg:"test"}',
51-
'userPublicKey' => '(stringOf88Chars)',
52-
'userAuthToken' => '(stringOf24Chars)',
5359
],
5460
];
5561

@@ -58,20 +64,16 @@ $webPush = new WebPush();
5864
// send multiple notifications with payload
5965
foreach ($notifications as $notification) {
6066
$webPush->sendNotification(
61-
$notification['endpoint'],
62-
$notification['payload'], // optional (defaults null)
63-
$notification['userPublicKey'], // optional (defaults null)
64-
$notification['userAuthToken'] // optional (defaults null)
67+
$notification['subscription'],
68+
$notification['payload'] // optional (defaults null)
6569
);
6670
}
6771
$webPush->flush();
6872

6973
// send one notification and flush directly
7074
$webPush->sendNotification(
71-
$notifications[0]['endpoint'],
75+
$notifications[0]['subscription'],
7276
$notifications[0]['payload'], // optional (defaults null)
73-
$notifications[0]['userPublicKey'], // optional (defaults null)
74-
$notifications[0]['userAuthToken'], // optional (defaults null)
7577
true // optional (defaults false)
7678
);
7779
```
@@ -150,7 +152,7 @@ $webPush = new WebPush([], $defaultOptions);
150152
$webPush->setDefaultOptions($defaultOptions);
151153

152154
// or for one notification
153-
$webPush->sendNotification($endpoint, $payload, $userPublicKey, $userAuthToken, $flush, ['TTL' => 5000]);
155+
$webPush->sendNotification($subscription, $payload, $flush, ['TTL' => 5000]);
154156
```
155157

156158
#### TTL
@@ -302,7 +304,7 @@ require __DIR__ . '/path/to/vendor/autoload.php';
302304
```
303305

304306
### I must use PHP 5.4 or 5.5. What can I do?
305-
You won't be able to send any payload, so you'll only be able to use `sendNotification($endpoint)`.
307+
You won't be able to send any payload, so you'll only be able to use `sendNotification($subscription)`.
306308
Install the library with `composer` using `--ignore-platform-reqs`.
307309
The workaround for getting the payload is to fetch it in the service worker ([example](https://github.com/Minishlink/physbook/blob/2ed8b9a8a217446c9747e9191a50d6312651125d/web/service-worker.js#L75)).
308310

src/Encryption.php

+104-27
Original file line numberDiff line numberDiff line change
@@ -26,36 +26,66 @@ class Encryption
2626

2727
/**
2828
* @param string $payload
29-
* @param int $maxLengthToPad
30-
*
29+
* @param int $maxLengthToPad
30+
* @param string $contentEncoding
3131
* @return string padded payload (plaintext)
32+
* @throws \ErrorException
3233
*/
33-
public static function padPayload(string $payload, int $maxLengthToPad): string
34+
public static function padPayload(string $payload, int $maxLengthToPad, string $contentEncoding): string
3435
{
3536
$payloadLen = Utils::safeStrlen($payload);
3637
$padLen = $maxLengthToPad ? $maxLengthToPad - $payloadLen : 0;
3738

38-
return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
39+
if ($contentEncoding === "aesgcm") {
40+
return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
41+
} else if ($contentEncoding === "aes128gcm") {
42+
return str_pad($payload.chr(2), $padLen + $payloadLen, chr(0), STR_PAD_RIGHT);
43+
} else {
44+
throw new \ErrorException("This content encoding is not supported");
45+
}
3946
}
4047

4148
/**
42-
* @param string $payload With padding
49+
* @param string $payload With padding
4350
* @param string $userPublicKey Base 64 encoded (MIME or URL-safe)
4451
* @param string $userAuthToken Base 64 encoded (MIME or URL-safe)
52+
* @param string $contentEncoding
53+
* @return array
4554
*
55+
* @throws \ErrorException
56+
*/
57+
public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding): array
58+
{
59+
return self::deterministicEncrypt(
60+
$payload,
61+
$userPublicKey,
62+
$userAuthToken,
63+
$contentEncoding,
64+
self::createLocalKeyObject(),
65+
random_bytes(16)
66+
);
67+
}
68+
69+
/**
70+
* @param string $payload
71+
* @param string $userPublicKey
72+
* @param string $userAuthToken
73+
* @param string $contentEncoding
74+
* @param array $localKeyObject
75+
* @param string $salt
4676
* @return array
4777
*
4878
* @throws \ErrorException
4979
*/
50-
public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken): array
80+
public static function deterministicEncrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding, array $localKeyObject, string $salt): array
5181
{
5282
$userPublicKey = Base64Url::decode($userPublicKey);
5383
$userAuthToken = Base64Url::decode($userAuthToken);
5484

5585
$curve = NistCurve::curve256();
5686

5787
// get local key pair
58-
list($localPublicKeyObject, $localPrivateKeyObject) = self::createLocalKeyObject();
88+
list($localPublicKeyObject, $localPrivateKeyObject) = $localKeyObject;
5989
$localPublicKey = hex2bin(Utils::serializePublicKey($localPublicKeyObject));
6090

6191
// get user public key object
@@ -69,23 +99,18 @@ public static function encrypt(string $payload, string $userPublicKey, string $u
6999
$sharedSecret = $curve->mul($userPublicKeyObject->getPoint(), $localPrivateKeyObject->getSecret())->getX();
70100
$sharedSecret = hex2bin(str_pad(gmp_strval($sharedSecret, 16), 64, '0', STR_PAD_LEFT));
71101

72-
// generate salt
73-
$salt = random_bytes(16);
74-
75102
// section 4.3
76-
$ikm = !empty($userAuthToken) ?
77-
self::hkdf($userAuthToken, $sharedSecret, 'Content-Encoding: auth'.chr(0), 32) :
78-
$sharedSecret;
103+
$ikm = self::getIKM($userAuthToken, $userPublicKey, $localPublicKey, $sharedSecret, $contentEncoding);
79104

80105
// section 4.2
81-
$context = self::createContext($userPublicKey, $localPublicKey);
106+
$context = self::createContext($userPublicKey, $localPublicKey, $contentEncoding);
82107

83108
// derive the Content Encryption Key
84-
$contentEncryptionKeyInfo = self::createInfo('aesgcm', $context);
109+
$contentEncryptionKeyInfo = self::createInfo($contentEncoding, $context, $contentEncoding);
85110
$contentEncryptionKey = self::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16);
86111

87112
// section 3.3, derive the nonce
88-
$nonceInfo = self::createInfo('nonce', $context);
113+
$nonceInfo = self::createInfo('nonce', $context, $contentEncoding);
89114
$nonce = self::hkdf($salt, $ikm, $nonceInfo, 12);
90115

91116
// encrypt
@@ -94,12 +119,24 @@ public static function encrypt(string $payload, string $userPublicKey, string $u
94119

95120
// return values in url safe base64
96121
return [
97-
'localPublicKey' => Base64Url::encode($localPublicKey),
98-
'salt' => Base64Url::encode($salt),
122+
'localPublicKey' => $localPublicKey,
123+
'salt' => $salt,
99124
'cipherText' => $encryptedText.$tag,
100125
];
101126
}
102127

128+
public static function getContentCodingHeader($salt, $localPublicKey, $contentEncoding): string
129+
{
130+
if ($contentEncoding === "aes128gcm") {
131+
return $salt
132+
.pack('N*', 4096)
133+
.pack('C*', Utils::safeStrlen($localPublicKey))
134+
.$localPublicKey;
135+
}
136+
137+
return "";
138+
}
139+
103140
/**
104141
* HMAC-based Extract-and-Expand Key Derivation Function (HKDF).
105142
*
@@ -138,12 +175,16 @@ private static function hkdf(string $salt, string $ikm, string $info, int $lengt
138175
* @param string $clientPublicKey The client's public key
139176
* @param string $serverPublicKey Our public key
140177
*
141-
* @return string
178+
* @return null|string
142179
*
143180
* @throws \ErrorException
144181
*/
145-
private static function createContext(string $clientPublicKey, string $serverPublicKey): string
182+
private static function createContext(string $clientPublicKey, string $serverPublicKey, $contentEncoding): ?string
146183
{
184+
if ($contentEncoding === "aes128gcm") {
185+
return null;
186+
}
187+
147188
if (Utils::safeStrlen($clientPublicKey) !== 65) {
148189
throw new \ErrorException('Invalid client public key length');
149190
}
@@ -163,20 +204,30 @@ private static function createContext(string $clientPublicKey, string $serverPub
163204
* {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
164205
* From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
165206
*
166-
* @param string $type The type of the info record
167-
* @param string $context The context for the record
168-
*
207+
* @param string $type The type of the info record
208+
* @param string|null $context The context for the record
209+
* @param string $contentEncoding
169210
* @return string
170211
*
171212
* @throws \ErrorException
172213
*/
173-
private static function createInfo(string $type, string $context): string
214+
private static function createInfo(string $type, ?string $context, string $contentEncoding): string
174215
{
175-
if (Utils::safeStrlen($context) !== 135) {
176-
throw new \ErrorException('Context argument has invalid size');
216+
if ($contentEncoding === "aesgcm") {
217+
if (!$context) {
218+
throw new \ErrorException('Context must exist');
219+
}
220+
221+
if (Utils::safeStrlen($context) !== 135) {
222+
throw new \ErrorException('Context argument has invalid size');
223+
}
224+
225+
return 'Content-Encoding: '.$type.chr(0).'P-256'.$context;
226+
} else if ($contentEncoding === "aes128gcm") {
227+
return 'Content-Encoding: '.$type.chr(0);
177228
}
178229

179-
return 'Content-Encoding: '.$type.chr(0).'P-256'.$context;
230+
throw new \ErrorException('This content encoding is not supported.');
180231
}
181232

182233
/**
@@ -234,4 +285,30 @@ private static function createLocalKeyObjectUsingOpenSSL(): array
234285
PrivateKey::create(gmp_init(bin2hex($details['ec']['d']), 16))
235286
];
236287
}
288+
289+
/**
290+
* @param string $userAuthToken
291+
* @param string $userPublicKey
292+
* @param string $localPublicKey
293+
* @param string $sharedSecret
294+
* @param string $contentEncoding
295+
* @return string
296+
* @throws \ErrorException
297+
*/
298+
private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, string $contentEncoding): string
299+
{
300+
if (!empty($userAuthToken)) {
301+
if ($contentEncoding === "aesgcm") {
302+
$info = 'Content-Encoding: auth'.chr(0);
303+
} else if ($contentEncoding === "aes128gcm") {
304+
$info = "WebPush: info".chr(0).$userPublicKey.$localPublicKey;
305+
} else {
306+
throw new \ErrorException("This content encoding is not supported");
307+
}
308+
309+
return self::hkdf($userAuthToken, $sharedSecret, $info, 32);
310+
}
311+
312+
return $sharedSecret;
313+
}
237314
}

src/Notification.php

+10-36
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,12 @@
1515

1616
class Notification
1717
{
18-
/** @var string */
19-
private $endpoint;
18+
/** @var Subscription */
19+
private $subscription;
2020

2121
/** @var null|string */
2222
private $payload;
2323

24-
/** @var null|string */
25-
private $userPublicKey;
26-
27-
/** @var null|string */
28-
private $userAuthToken;
29-
3024
/** @var array Options : TTL, urgency, topic */
3125
private $options;
3226

@@ -36,29 +30,25 @@ class Notification
3630
/**
3731
* Notification constructor.
3832
*
39-
* @param string $endpoint
33+
* @param Subscription $subscription
4034
* @param null|string $payload
41-
* @param null|string $userPublicKey
42-
* @param null|string $userAuthToken
43-
* @param array $options
44-
* @param array $auth
35+
* @param array $options
36+
* @param array $auth
4537
*/
46-
public function __construct(string $endpoint, ?string $payload, ?string $userPublicKey, ?string $userAuthToken, array $options, array $auth)
38+
public function __construct(Subscription $subscription, ?string $payload, array $options, array $auth)
4739
{
48-
$this->endpoint = $endpoint;
40+
$this->subscription = $subscription;
4941
$this->payload = $payload;
50-
$this->userPublicKey = $userPublicKey;
51-
$this->userAuthToken = $userAuthToken;
5242
$this->options = $options;
5343
$this->auth = $auth;
5444
}
5545

5646
/**
57-
* @return string
47+
* @return Subscription
5848
*/
59-
public function getEndpoint(): string
49+
public function getSubscription(): Subscription
6050
{
61-
return $this->endpoint;
51+
return $this->subscription;
6252
}
6353

6454
/**
@@ -69,22 +59,6 @@ public function getPayload(): ?string
6959
return $this->payload;
7060
}
7161

72-
/**
73-
* @return null|string
74-
*/
75-
public function getUserPublicKey(): ?string
76-
{
77-
return $this->userPublicKey;
78-
}
79-
80-
/**
81-
* @return null|string
82-
*/
83-
public function getUserAuthToken(): ?string
84-
{
85-
return $this->userAuthToken;
86-
}
87-
8862
/**
8963
* @param array $defaultOptions
9064
*

0 commit comments

Comments
 (0)