Skip to content

Commit 9ef274a

Browse files
committed
feat: convert contentEncoding to typesafe enum
1 parent 7b6d1e9 commit 9ef274a

9 files changed

+126
-56
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ $notifications = [
6666
'p256dh' => '(stringOf88Chars)',
6767
'auth' => '(stringOf24Chars)'
6868
],
69+
// key 'contentEncoding' is optional and defaults to Subscription::defaultContentEncoding
6970
]),
7071
'payload' => '{"message":"Hello World!"}',
7172
], [

src/ContentEncoding.php

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Minishlink\WebPush;
4+
5+
enum ContentEncoding: string
6+
{
7+
/** Outdated historic encoding. Was used by some browsers before rfc standard. Not recommended. */
8+
case aesgcm = "aesgcm";
9+
/** Defined in rfc8291. */
10+
case aes128gcm = "aes128gcm";
11+
}

src/Encryption.php

+34-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");
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,17 @@ 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+
if ($contentEncoding === ContentEncoding::aesgcm) {
271280
$info = 'Content-Encoding: auth'.chr(0);
272-
} elseif ($contentEncoding === "aes128gcm") {
281+
} elseif ($contentEncoding === ContentEncoding::aes128gcm) {
273282
$info = "WebPush: info".chr(0).$userPublicKey.$localPublicKey;
274283
} else {
275-
throw new \ValueError("This content encoding is not supported.");
284+
throw new \ValueError("This content encoding is not implemented.");
276285
}
277286

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

src/Subscription.php

+24-8
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,33 @@
1515

1616
class Subscription implements SubscriptionInterface
1717
{
18+
public const defaultContentEncoding = ContentEncoding::aesgcm; // Default for legacy input. The next mayor will use "aes128gcm" as defined to rfc8291.
19+
protected ?ContentEncoding $contentEncoding = null;
20+
1821
/**
19-
* @param string|null $contentEncoding (Optional) Must be "aesgcm"
22+
* This is a data class. No key validation is done.
23+
* @param string|\Minishlink\WebPush\ContentEncoding $contentEncoding (Optional) defaults to "aesgcm". The next mayor will use "aes128gcm" as defined to rfc8291.
2024
* @throws \ErrorException
2125
*/
2226
public function __construct(
2327
private string $endpoint,
2428
private ?string $publicKey = null,
2529
private ?string $authToken = null,
26-
private ?string $contentEncoding = null
30+
ContentEncoding|string|null $contentEncoding = null,
2731
) {
2832
if ($publicKey || $authToken || $contentEncoding) {
29-
$supportedContentEncodings = ['aesgcm', 'aes128gcm'];
30-
if ($contentEncoding && !in_array($contentEncoding, $supportedContentEncodings, true)) {
31-
throw new \ErrorException('This content encoding ('.$contentEncoding.') is not supported.');
33+
if (is_string($contentEncoding)) {
34+
try {
35+
if (empty($contentEncoding)) {
36+
$contentEncoding = self::defaultContentEncoding;
37+
} else {
38+
$contentEncoding = ContentEncoding::from($contentEncoding);
39+
}
40+
} catch (\ValueError) {
41+
throw new \ValueError('This content encoding ('.$contentEncoding.') is not supported.');
42+
}
3243
}
33-
$this->contentEncoding = $contentEncoding ?: "aesgcm";
44+
$this->contentEncoding = $contentEncoding ?: ContentEncoding::aesgcm;
3445
}
3546
}
3647

@@ -45,7 +56,7 @@ public static function create(array $associativeArray): self
4556
$associativeArray['endpoint'],
4657
$associativeArray['keys']['p256dh'] ?? null,
4758
$associativeArray['keys']['auth'] ?? null,
48-
$associativeArray['contentEncoding'] ?? "aesgcm"
59+
$associativeArray['contentEncoding'] ?? ContentEncoding::aesgcm,
4960
);
5061
}
5162

@@ -54,7 +65,7 @@ public static function create(array $associativeArray): self
5465
$associativeArray['endpoint'],
5566
$associativeArray['publicKey'] ?? null,
5667
$associativeArray['authToken'] ?? null,
57-
$associativeArray['contentEncoding'] ?? "aesgcm"
68+
$associativeArray['contentEncoding'] ?? ContentEncoding::aesgcm,
5869
);
5970
}
6071

@@ -91,6 +102,11 @@ public function getAuthToken(): ?string
91102
* {@inheritDoc}
92103
*/
93104
public function getContentEncoding(): ?string
105+
{
106+
return $this->contentEncoding?->value;
107+
}
108+
109+
public function getContentEncodingTyped(): ?ContentEncoding
94110
{
95111
return $this->contentEncoding;
96112
}

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

+8-8
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public function queueNotification(SubscriptionInterface $subscription, ?string $
9999
throw new \ErrorException('Subscription should have a content encoding');
100100
}
101101

102-
$payload = Encryption::padPayload($payload, $this->automaticPadding, $contentEncoding);
102+
$payload = Encryption::padPayload($payload, $this->automaticPadding, ContentEncoding::from($contentEncoding));
103103
}
104104

105105
if (array_key_exists('VAPID', $auth)) {
@@ -257,7 +257,7 @@ protected function prepare(array $notifications): array
257257
throw new \ErrorException('Subscription should have a content encoding');
258258
}
259259

260-
$encrypted = Encryption::encrypt($payload, $userPublicKey, $userAuthToken, $contentEncoding);
260+
$encrypted = Encryption::encrypt($payload, $userPublicKey, $userAuthToken, ContentEncoding::from($contentEncoding));
261261
$cipherText = $encrypted['cipherText'];
262262
$salt = $encrypted['salt'];
263263
$localPublicKey = $encrypted['localPublicKey'];
@@ -267,12 +267,12 @@ protected function prepare(array $notifications): array
267267
'Content-Encoding' => $contentEncoding,
268268
];
269269

270-
if ($contentEncoding === "aesgcm") {
270+
if ($contentEncoding === ContentEncoding::aesgcm->value) {
271271
$headers['Encryption'] = 'salt='.Base64Url::encode($salt);
272272
$headers['Crypto-Key'] = 'dh='.Base64Url::encode($localPublicKey);
273273
}
274274

275-
$encryptionContentCodingHeader = Encryption::getContentCodingHeader($salt, $localPublicKey, $contentEncoding);
275+
$encryptionContentCodingHeader = Encryption::getContentCodingHeader($salt, $localPublicKey, ContentEncoding::from($contentEncoding));
276276
$content = $encryptionContentCodingHeader.$cipherText;
277277

278278
$headers['Content-Length'] = (string) Utils::safeStrlen($content);
@@ -300,11 +300,11 @@ protected function prepare(array $notifications): array
300300
throw new \ErrorException('Audience "'.$audience.'"" could not be generated.');
301301
}
302302

303-
$vapidHeaders = $this->getVAPIDHeaders($audience, $contentEncoding, $auth['VAPID']);
303+
$vapidHeaders = $this->getVAPIDHeaders($audience, ContentEncoding::from($contentEncoding), $auth['VAPID']);
304304

305305
$headers['Authorization'] = $vapidHeaders['Authorization'];
306306

307-
if ($contentEncoding === 'aesgcm') {
307+
if ($contentEncoding === ContentEncoding::aesgcm->value) {
308308
if (array_key_exists('Crypto-Key', $headers)) {
309309
$headers['Crypto-Key'] .= ';'.$vapidHeaders['Crypto-Key'];
310310
} else {
@@ -397,13 +397,13 @@ public function countPendingNotifications(): int
397397
/**
398398
* @throws \ErrorException
399399
*/
400-
protected function getVAPIDHeaders(string $audience, string $contentEncoding, array $vapid): ?array
400+
protected function getVAPIDHeaders(string $audience, ContentEncoding $contentEncoding, array $vapid): ?array
401401
{
402402
$vapidHeaders = null;
403403

404404
$cache_key = null;
405405
if ($this->reuseVAPIDHeaders) {
406-
$cache_key = implode('#', [$audience, $contentEncoding, crc32(serialize($vapid))]);
406+
$cache_key = implode('#', [$audience, $contentEncoding->value, crc32(serialize($vapid))]);
407407
if (array_key_exists($cache_key, $this->vapidHeaders)) {
408408
$vapidHeaders = $this->vapidHeaders[$cache_key];
409409
}

tests/EncryptionTest.php

+4-3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
use Base64Url\Base64Url;
1212
use Jose\Component\Core\JWK;
13+
use Minishlink\WebPush\ContentEncoding;
1314
use Minishlink\WebPush\Encryption;
1415
use Minishlink\WebPush\Utils;
1516
use PHPUnit\Framework\Attributes\CoversClass;
@@ -37,7 +38,7 @@ public function testBase64Decode(): void
3738

3839
public function testDeterministicEncrypt(): void
3940
{
40-
$contentEncoding = 'aes128gcm';
41+
$contentEncoding = ContentEncoding::aes128gcm;
4142
$plaintext = 'When I grow up, I want to be a watermelon';
4243

4344
$payload = Encryption::padPayload($plaintext, 0, $contentEncoding);
@@ -83,7 +84,7 @@ public function testGetContentCodingHeader(): void
8384
$localPublicKey = $this->base64Decode('BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8');
8485
$salt = $this->base64Decode('DGv6ra1nlYgDCS1FRnbzlw');
8586

86-
$result = Encryption::getContentCodingHeader($salt, $localPublicKey, "aes128gcm");
87+
$result = Encryption::getContentCodingHeader($salt, $localPublicKey, ContentEncoding::aes128gcm);
8788
$expected = $this->base64Decode('DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8');
8889

8990
$this->assertEquals(Utils::safeStrlen($expected), Utils::safeStrlen($result));
@@ -96,7 +97,7 @@ public function testGetContentCodingHeader(): void
9697
#[dataProvider('payloadProvider')]
9798
public function testPadPayload(string $payload, int $maxLengthToPad, int $expectedResLength): void
9899
{
99-
$res = Encryption::padPayload($payload, $maxLengthToPad, 'aesgcm');
100+
$res = Encryption::padPayload($payload, $maxLengthToPad, ContentEncoding::aesgcm);
100101

101102
$this->assertStringContainsString('test', $res);
102103
$this->assertEquals($expectedResLength, Utils::safeStrlen($res));

0 commit comments

Comments
 (0)