Skip to content

Commit 59f2e6e

Browse files
committed
feat: convert contentEncoding to typesafe enum
[BREAKING] change default encoding to aes128gcm
1 parent d947cd0 commit 59f2e6e

10 files changed

+126
-82
lines changed

README.md

+6-5
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ A complete example with html+JS frontend and php backend using `web-push-php` ca
4949
use Minishlink\WebPush\WebPush;
5050
use Minishlink\WebPush\Subscription;
5151

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

55-
// array of notifications
55+
// Array of push messages.
5656
$notifications = [
5757
[
5858
'subscription' => $subscription,
@@ -65,6 +65,7 @@ $notifications = [
6565
'p256dh' => '(stringOf88Chars)',
6666
'auth' => '(stringOf24Chars)',
6767
],
68+
// key 'contentEncoding' is optional and defaults to ContentEncoding::aes128gcm
6869
]),
6970
'payload' => '{"message":"Hello World!"}',
7071
], [
@@ -81,7 +82,7 @@ $notifications = [
8182

8283
$webPush = new WebPush();
8384

84-
// send multiple notifications with payload
85+
// Send multiple push messages with payload.
8586
foreach ($notifications as $notification) {
8687
$webPush->queueNotification(
8788
$notification['subscription'],
@@ -90,7 +91,7 @@ foreach ($notifications as $notification) {
9091
}
9192

9293
/**
93-
* Check sent results
94+
* Check sent results.
9495
* @var MessageSentReport $report
9596
*/
9697
foreach ($webPush->flush() as $report) {
@@ -104,7 +105,7 @@ foreach ($webPush->flush() as $report) {
104105
}
105106

106107
/**
107-
* send one notification and flush directly
108+
* Send one push message and flush directly.
108109
* @var MessageSentReport $report
109110
*/
110111
$report = $webPush->sendOneNotification(

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

+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 = Base64UrlSafe::decodeNoPadding($userPublicKey);
7076
$userAuthToken = Base64UrlSafe::decodeNoPadding($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

+28-21
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,32 @@
1515

1616
class Subscription implements SubscriptionInterface
1717
{
18+
protected ContentEncoding $contentEncoding;
1819
/**
19-
* @param string $contentEncoding (Optional) defaults to "aesgcm"
20+
* This is a data class. No key validation is done.
21+
* @param string|\Minishlink\WebPush\ContentEncoding $contentEncoding (Optional) defaults to "aes128gcm" as defined to rfc8291.
2022
* @throws \ErrorException
2123
*/
2224
public function __construct(
23-
private readonly string $endpoint,
24-
private readonly string $publicKey,
25-
private readonly string $authToken,
26-
private readonly string $contentEncoding = "aesgcm",
25+
protected readonly string $endpoint,
26+
protected readonly string $publicKey,
27+
protected readonly string $authToken,
28+
ContentEncoding|string $contentEncoding = ContentEncoding::aes128gcm,
2729
) {
28-
$supportedContentEncodings = ['aesgcm', 'aes128gcm'];
29-
if ($contentEncoding && !in_array($contentEncoding, $supportedContentEncodings, true)) {
30-
throw new \ErrorException('This content encoding ('.$contentEncoding.') is not supported.');
30+
if(is_string($contentEncoding)) {
31+
try {
32+
if(empty($contentEncoding)) {
33+
$this->contentEncoding = ContentEncoding::aesgcm; // default
34+
} else {
35+
$this->contentEncoding = ContentEncoding::from($contentEncoding);
36+
}
37+
} catch(\ValueError) {
38+
throw new \ValueError('This content encoding ('.$contentEncoding.') is not supported.');
39+
}
40+
} else {
41+
$this->contentEncoding = $contentEncoding;
3142
}
32-
if(empty($publicKey) || empty($authToken) || empty($contentEncoding)) {
43+
if(empty($publicKey) || empty($authToken)) {
3344
throw new \ValueError('Missing values.');
3445
}
3546
}
@@ -45,20 +56,16 @@ public static function create(array $associativeArray): self
4556
$associativeArray['endpoint'] ?? "",
4657
$associativeArray['keys']['p256dh'] ?? "",
4758
$associativeArray['keys']['auth'] ?? "",
48-
$associativeArray['contentEncoding'] ?? "aesgcm"
59+
$associativeArray['contentEncoding'] ?? ContentEncoding::aes128gcm,
4960
);
5061
}
5162

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.');
63+
return new self(
64+
$associativeArray['endpoint'] ?? "",
65+
$associativeArray['publicKey'] ?? "",
66+
$associativeArray['authToken'] ?? "",
67+
$associativeArray['contentEncoding'] ?? ContentEncoding::aes128gcm,
68+
);
6269
}
6370

6471
public function getEndpoint(): string
@@ -76,7 +83,7 @@ public function getAuthToken(): string
7683
return $this->authToken;
7784
}
7885

79-
public function getContentEncoding(): string
86+
public function getContentEncoding(): ContentEncoding
8087
{
8188
return $this->contentEncoding;
8289
}

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 = Base64UrlSafe::encodeUnpadded($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
];

0 commit comments

Comments
 (0)