Skip to content

Commit e632f72

Browse files
committed
feat: convert contentEncoding to typesafe enum
[BREAKING] change default encoding to aes128gcm
1 parent 8d01790 commit e632f72

10 files changed

+123
-82
lines changed

README.md

+6-5
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ Use [composer](https://getcomposer.org/) to download and install the library and
3636
use Minishlink\WebPush\WebPush;
3737
use Minishlink\WebPush\Subscription;
3838

39-
// store the client-side `PushSubscription` object (calling `.toJSON` on it) as-is and then create a WebPush\Subscription from it
39+
// Store the client-side `PushSubscription` object (calling `.toJSON` on it) as-is and then create a WebPush\Subscription from it.
4040
$subscription = Subscription::create(json_decode($clientSidePushSubscriptionJSON, true));
4141

42-
// array of notifications
42+
// Array of push messages.
4343
$notifications = [
4444
[
4545
'subscription' => $subscription,
@@ -52,6 +52,7 @@ $notifications = [
5252
'p256dh' => '(stringOf88Chars)',
5353
'auth' => '(stringOf24Chars)',
5454
],
55+
// key 'contentEncoding' is optional and defaults to ContentEncoding::aes128gcm
5556
]),
5657
'payload' => '{"message":"Hello World!"}',
5758
], [
@@ -68,7 +69,7 @@ $notifications = [
6869

6970
$webPush = new WebPush();
7071

71-
// send multiple notifications with payload
72+
// Send multiple push messages with payload.
7273
foreach ($notifications as $notification) {
7374
$webPush->queueNotification(
7475
$notification['subscription'],
@@ -77,7 +78,7 @@ foreach ($notifications as $notification) {
7778
}
7879

7980
/**
80-
* Check sent results
81+
* Check sent results.
8182
* @var MessageSentReport $report
8283
*/
8384
foreach ($webPush->flush() as $report) {
@@ -91,7 +92,7 @@ foreach ($webPush->flush() as $report) {
9192
}
9293

9394
/**
94-
* send one notification and flush directly
95+
* Send one push message and flush directly.
9596
* @var MessageSentReport $report
9697
*/
9798
$report = $webPush->sendOneNotification(

src/ContentEncoding.php

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Minishlink\WebPush;
4+
5+
enum ContentEncoding: string
6+
{
7+
case aesgcm = "aesgcm";
8+
case aes128gcm = "aes128gcm";
9+
}

src/Encryption.php

+35-25
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,19 @@ class Encryption
2727
* @return string padded payload (plaintext)
2828
* @throws \ErrorException
2929
*/
30-
public static function padPayload(string $payload, int $maxLengthToPad, string $contentEncoding): string
30+
public static function padPayload(string $payload, int $maxLengthToPad, ContentEncoding $contentEncoding): string
3131
{
3232
$payloadLen = Utils::safeStrlen($payload);
3333
$padLen = $maxLengthToPad ? $maxLengthToPad - $payloadLen : 0;
3434

35-
if ($contentEncoding === "aesgcm") {
35+
if ($contentEncoding === ContentEncoding::aesgcm) {
3636
return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
3737
}
38-
if ($contentEncoding === "aes128gcm") {
38+
if ($contentEncoding === ContentEncoding::aes128gcm) {
3939
return str_pad($payload.chr(2), $padLen + $payloadLen, chr(0), STR_PAD_RIGHT);
4040
}
4141

42-
throw new \ErrorException("This content encoding is not supported: ".$contentEncoding);
42+
throw new \ErrorException("This content encoding is not implemented.");
4343
}
4444

4545
/**
@@ -49,7 +49,7 @@ public static function padPayload(string $payload, int $maxLengthToPad, string $
4949
*
5050
* @throws \ErrorException
5151
*/
52-
public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding): array
52+
public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken, ContentEncoding $contentEncoding): array
5353
{
5454
return self::deterministicEncrypt(
5555
$payload,
@@ -64,8 +64,14 @@ public static function encrypt(string $payload, string $userPublicKey, string $u
6464
/**
6565
* @throws \RuntimeException
6666
*/
67-
public static function deterministicEncrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding, array $localKeyObject, string $salt): array
68-
{
67+
public static function deterministicEncrypt(
68+
string $payload,
69+
string $userPublicKey,
70+
string $userAuthToken,
71+
ContentEncoding $contentEncoding,
72+
array $localKeyObject,
73+
string $salt
74+
): array {
6975
$userPublicKey = Base64Url::decode($userPublicKey);
7076
$userAuthToken = Base64Url::decode($userAuthToken);
7177

@@ -112,7 +118,7 @@ public static function deterministicEncrypt(string $payload, string $userPublicK
112118
$context = self::createContext($userPublicKey, $localPublicKey, $contentEncoding);
113119

114120
// derive the Content Encryption Key
115-
$contentEncryptionKeyInfo = self::createInfo($contentEncoding, $context, $contentEncoding);
121+
$contentEncryptionKeyInfo = self::createInfo($contentEncoding->value, $context, $contentEncoding);
116122
$contentEncryptionKey = self::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16);
117123

118124
// section 3.3, derive the nonce
@@ -132,16 +138,19 @@ public static function deterministicEncrypt(string $payload, string $userPublicK
132138
];
133139
}
134140

135-
public static function getContentCodingHeader(string $salt, string $localPublicKey, string $contentEncoding): string
141+
public static function getContentCodingHeader(string $salt, string $localPublicKey, ContentEncoding $contentEncoding): string
136142
{
137-
if ($contentEncoding === "aes128gcm") {
143+
if ($contentEncoding === ContentEncoding::aesgcm) {
144+
return "";
145+
}
146+
if ($contentEncoding === ContentEncoding::aes128gcm) {
138147
return $salt
139148
.pack('N*', 4096)
140149
.pack('C*', Utils::safeStrlen($localPublicKey))
141150
.$localPublicKey;
142151
}
143152

144-
return "";
153+
throw new \ValueError("This content encoding is not implemented.");
145154
}
146155

147156
/**
@@ -182,19 +191,19 @@ private static function hkdf(string $salt, string $ikm, string $info, int $lengt
182191
*
183192
* @throws \ErrorException
184193
*/
185-
private static function createContext(string $clientPublicKey, string $serverPublicKey, string $contentEncoding): ?string
194+
private static function createContext(string $clientPublicKey, string $serverPublicKey, ContentEncoding $contentEncoding): ?string
186195
{
187-
if ($contentEncoding === "aes128gcm") {
196+
if ($contentEncoding === ContentEncoding::aes128gcm) {
188197
return null;
189198
}
190199

191200
if (Utils::safeStrlen($clientPublicKey) !== 65) {
192-
throw new \ErrorException('Invalid client public key length');
201+
throw new \ErrorException('Invalid client public key length.');
193202
}
194203

195204
// This one should never happen, because it's our code that generates the key
196205
if (Utils::safeStrlen($serverPublicKey) !== 65) {
197-
throw new \ErrorException('Invalid server public key length');
206+
throw new \ErrorException('Invalid server public key length.');
198207
}
199208

200209
$len = chr(0).'A'; // 65 as Uint16BE
@@ -212,25 +221,25 @@ private static function createContext(string $clientPublicKey, string $serverPub
212221
*
213222
* @throws \ErrorException
214223
*/
215-
private static function createInfo(string $type, ?string $context, string $contentEncoding): string
224+
private static function createInfo(string $type, ?string $context, ContentEncoding $contentEncoding): string
216225
{
217-
if ($contentEncoding === "aesgcm") {
226+
if ($contentEncoding === ContentEncoding::aesgcm) {
218227
if (!$context) {
219-
throw new \ErrorException('Context must exist');
228+
throw new \ValueError('Context must exist.');
220229
}
221230

222231
if (Utils::safeStrlen($context) !== 135) {
223-
throw new \ErrorException('Context argument has invalid size');
232+
throw new \ValueError('Context argument has invalid size.');
224233
}
225234

226235
return 'Content-Encoding: '.$type.chr(0).'P-256'.$context;
227236
}
228237

229-
if ($contentEncoding === "aes128gcm") {
238+
if ($contentEncoding === ContentEncoding::aes128gcm) {
230239
return 'Content-Encoding: '.$type.chr(0);
231240
}
232241

233-
throw new \ErrorException('This content encoding is not supported.');
242+
throw new \ErrorException('This content encoding is not implemented.');
234243
}
235244

236245
private static function createLocalKeyObject(): array
@@ -262,17 +271,18 @@ private static function createLocalKeyObject(): array
262271
/**
263272
* @throws \ValueError
264273
*/
265-
private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, string $contentEncoding): string
274+
private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, ContentEncoding $contentEncoding): string
266275
{
267276
if (empty($userAuthToken)) {
268277
return $sharedSecret;
269278
}
270-
if($contentEncoding === "aesgcm") {
279+
280+
if ($contentEncoding === ContentEncoding::aesgcm) {
271281
$info = 'Content-Encoding: auth'.chr(0);
272-
} elseif($contentEncoding === "aes128gcm") {
282+
} elseif ($contentEncoding === ContentEncoding::aes128gcm) {
273283
$info = "WebPush: info".chr(0).$userPublicKey.$localPublicKey;
274284
} else {
275-
throw new \ValueError("This content encoding is not supported.");
285+
throw new \ValueError("This content encoding is not implemented.");
276286
}
277287

278288
return self::hkdf($userAuthToken, $sharedSecret, $info, 32);

src/Subscription.php

+27-21
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,31 @@
1515

1616
class Subscription implements SubscriptionInterface
1717
{
18+
protected ContentEncoding $contentEncoding;
1819
/**
19-
* @param string $contentEncoding (Optional) defaults to "aesgcm"
20+
* @param string|\Minishlink\WebPush\ContentEncoding $contentEncoding (Optional) defaults to "aes128gcm" as defined to rfc8291.
2021
* @throws \ErrorException
2122
*/
2223
public function __construct(
23-
private readonly string $endpoint,
24-
private readonly string $publicKey,
25-
private readonly string $authToken,
26-
private readonly string $contentEncoding = "aesgcm",
24+
protected readonly string $endpoint,
25+
protected readonly string $publicKey,
26+
protected readonly string $authToken,
27+
ContentEncoding|string $contentEncoding = ContentEncoding::aes128gcm,
2728
) {
28-
$supportedContentEncodings = ['aesgcm', 'aes128gcm'];
29-
if ($contentEncoding && !in_array($contentEncoding, $supportedContentEncodings, true)) {
30-
throw new \ErrorException('This content encoding ('.$contentEncoding.') is not supported.');
29+
if(is_string($contentEncoding)) {
30+
try {
31+
if(empty($contentEncoding)) {
32+
$this->contentEncoding = ContentEncoding::aesgcm; // default
33+
} else {
34+
$this->contentEncoding = ContentEncoding::from($contentEncoding);
35+
}
36+
} catch(\ValueError) {
37+
throw new \ValueError('This content encoding ('.$contentEncoding.') is not supported.');
38+
}
39+
} else {
40+
$this->contentEncoding = $contentEncoding;
3141
}
32-
if(empty($publicKey) || empty($authToken) || empty($contentEncoding)) {
42+
if(empty($publicKey) || empty($authToken)) {
3343
throw new \ValueError('Missing values.');
3444
}
3545
}
@@ -45,20 +55,16 @@ public static function create(array $associativeArray): self
4555
$associativeArray['endpoint'] ?? "",
4656
$associativeArray['keys']['p256dh'] ?? "",
4757
$associativeArray['keys']['auth'] ?? "",
48-
$associativeArray['contentEncoding'] ?? "aesgcm"
58+
$associativeArray['contentEncoding'] ?? ContentEncoding::aes128gcm,
4959
);
5060
}
5161

52-
if (array_key_exists('publicKey', $associativeArray) || array_key_exists('authToken', $associativeArray) || array_key_exists('contentEncoding', $associativeArray)) {
53-
return new self(
54-
$associativeArray['endpoint'] ?? "",
55-
$associativeArray['publicKey'] ?? "",
56-
$associativeArray['authToken'] ?? "",
57-
$associativeArray['contentEncoding'] ?? "aesgcm"
58-
);
59-
}
60-
61-
throw new \ValueError('Missing values.');
62+
return new self(
63+
$associativeArray['endpoint'] ?? "",
64+
$associativeArray['publicKey'] ?? "",
65+
$associativeArray['authToken'] ?? "",
66+
$associativeArray['contentEncoding'] ?? ContentEncoding::aes128gcm,
67+
);
6268
}
6369

6470
public function getEndpoint(): string
@@ -76,7 +82,7 @@ public function getAuthToken(): string
7682
return $this->authToken;
7783
}
7884

79-
public function getContentEncoding(): string
85+
public function getContentEncoding(): ContentEncoding
8086
{
8187
return $this->contentEncoding;
8288
}

src/SubscriptionInterface.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@ public function getPublicKey(): string;
2525

2626
public function getAuthToken(): string;
2727

28-
public function getContentEncoding(): string;
28+
public function getContentEncoding(): ContentEncoding;
2929
}

src/VAPID.php

+3-3
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ public static function validate(array $vapid): array
9797
* @return array Returns an array with the 'Authorization' and 'Crypto-Key' values to be used as headers
9898
* @throws \ErrorException
9999
*/
100-
public static function getVapidHeaders(string $audience, string $subject, string $publicKey, string $privateKey, string $contentEncoding, ?int $expiration = null): array
100+
public static function getVapidHeaders(string $audience, string $subject, string $publicKey, string $privateKey, ContentEncoding $contentEncoding, ?int $expiration = null): array
101101
{
102102
$expirationLimit = time() + 43200; // equal margin of error between 0 and 24h
103103
if (null === $expiration || $expiration > $expirationLimit) {
@@ -138,14 +138,14 @@ public static function getVapidHeaders(string $audience, string $subject, string
138138
$jwt = $jwsCompactSerializer->serialize($jws, 0);
139139
$encodedPublicKey = Base64Url::encode($publicKey);
140140

141-
if ($contentEncoding === "aesgcm") {
141+
if ($contentEncoding === ContentEncoding::aesgcm) {
142142
return [
143143
'Authorization' => 'WebPush '.$jwt,
144144
'Crypto-Key' => 'p256ecdsa='.$encodedPublicKey,
145145
];
146146
}
147147

148-
if ($contentEncoding === 'aes128gcm') {
148+
if ($contentEncoding === ContentEncoding::aes128gcm) {
149149
return [
150150
'Authorization' => 'vapid t='.$jwt.', k='.$encodedPublicKey,
151151
];

src/WebPush.php

+6-14
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,6 @@ public function queueNotification(SubscriptionInterface $subscription, ?string $
9292
}
9393

9494
$contentEncoding = $subscription->getContentEncoding();
95-
if (!$contentEncoding) {
96-
throw new \ErrorException('Subscription should have a content encoding');
97-
}
98-
9995
$payload = Encryption::padPayload($payload, $this->automaticPadding, $contentEncoding);
10096
}
10197

@@ -193,21 +189,17 @@ protected function prepare(array $notifications): array
193189
$auth = $notification->getAuth($this->auth);
194190

195191
if (!empty($payload) && !empty($userPublicKey) && !empty($userAuthToken)) {
196-
if (!$contentEncoding) {
197-
throw new \ErrorException('Subscription should have a content encoding');
198-
}
199-
200192
$encrypted = Encryption::encrypt($payload, $userPublicKey, $userAuthToken, $contentEncoding);
201193
$cipherText = $encrypted['cipherText'];
202194
$salt = $encrypted['salt'];
203195
$localPublicKey = $encrypted['localPublicKey'];
204196

205197
$headers = [
206198
'Content-Type' => 'application/octet-stream',
207-
'Content-Encoding' => $contentEncoding,
199+
'Content-Encoding' => $contentEncoding->value,
208200
];
209201

210-
if ($contentEncoding === "aesgcm") {
202+
if ($contentEncoding === ContentEncoding::aesgcm) {
211203
$headers['Encryption'] = 'salt='.Base64Url::encode($salt);
212204
$headers['Crypto-Key'] = 'dh='.Base64Url::encode($localPublicKey);
213205
}
@@ -234,7 +226,7 @@ protected function prepare(array $notifications): array
234226
$headers['Topic'] = $options['topic'];
235227
}
236228

237-
if (array_key_exists('VAPID', $auth) && $contentEncoding) {
229+
if (array_key_exists('VAPID', $auth)) {
238230
$audience = parse_url($endpoint, PHP_URL_SCHEME).'://'.parse_url($endpoint, PHP_URL_HOST);
239231
if (!parse_url($audience)) {
240232
throw new \ErrorException('Audience "'.$audience.'"" could not be generated.');
@@ -244,7 +236,7 @@ protected function prepare(array $notifications): array
244236

245237
$headers['Authorization'] = $vapidHeaders['Authorization'];
246238

247-
if ($contentEncoding === 'aesgcm') {
239+
if ($contentEncoding === ContentEncoding::aesgcm) {
248240
if (array_key_exists('Crypto-Key', $headers)) {
249241
$headers['Crypto-Key'] .= ';'.$vapidHeaders['Crypto-Key'];
250242
} else {
@@ -335,13 +327,13 @@ public function countPendingNotifications(): int
335327
/**
336328
* @throws \ErrorException
337329
*/
338-
protected function getVAPIDHeaders(string $audience, string $contentEncoding, array $vapid): ?array
330+
protected function getVAPIDHeaders(string $audience, ContentEncoding $contentEncoding, array $vapid): ?array
339331
{
340332
$vapidHeaders = null;
341333

342334
$cache_key = null;
343335
if ($this->reuseVAPIDHeaders) {
344-
$cache_key = implode('#', [$audience, $contentEncoding, crc32(serialize($vapid))]);
336+
$cache_key = implode('#', [$audience, $contentEncoding->value, crc32(serialize($vapid))]);
345337
if (array_key_exists($cache_key, $this->vapidHeaders)) {
346338
$vapidHeaders = $this->vapidHeaders[$cache_key];
347339
}

0 commit comments

Comments
 (0)