Skip to content

Commit b8b5e40

Browse files
committed
feat: convert contentEncoding to typesafe enum
1 parent 6e8ca8b commit b8b5e40

File tree

12 files changed

+140
-69
lines changed

12 files changed

+140
-69
lines changed

.php-cs-fixer.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
'@PSR12' => true, // The default rule.
77
'@autoPHPMigration' => true, // Uses min PHP version for regular migrations.
88
'blank_line_after_opening_tag' => false, // Do not waste space between <?php and declare.
9+
'declare_strict_types' => true,
910
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
10-
'php_unit_construct' => true,
1111
'php_unit_attributes' => true,
12+
'php_unit_construct' => true,
1213
'php_unit_method_casing' => true,
1314
'php_unit_test_class_requires_covers' => true,
1415
// Do not enable by default. These rules require review!! (but they are useful)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ $notifications = [
6767
'p256dh' => '(stringOf88Chars)',
6868
'auth' => '(stringOf24Chars)'
6969
],
70+
// key 'contentEncoding' is optional and defaults to Subscription::defaultContentEncoding
7071
]),
7172
'payload' => '{"message":"Hello World!"}',
7273
], [

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
"phpunit/phpunit": "^11.5.46|^12.5.2",
4646
"phpstan/phpstan": "^2.1.33",
4747
"friendsofphp/php-cs-fixer": "^v3.91.3",
48-
"symfony/polyfill-iconv": "^1.33"
48+
"symfony/polyfill-iconv": "^1.33",
49+
"phpstan/phpstan-strict-rules": "^2.0"
4950
},
5051
"autoload": {
5152
"psr-4": {

phpstan.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,9 @@ parameters:
55
reportUnmatchedIgnoredErrors: false
66
ignoreErrors:
77
- identifier: missingType.iterableValue
8+
strictRules:
9+
booleansInConditions: false
10+
disallowedEmpty: false
11+
12+
includes:
13+
- vendor/phpstan/phpstan-strict-rules/rules.neon

src/ContentEncoding.php

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

src/Encryption.php

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
<?php
2-
3-
declare(strict_types=1);
1+
<?php declare(strict_types=1);
42

53
/*
64
* This file is part of the WebPush library.
@@ -27,35 +25,35 @@ class Encryption
2725
* @return string padded payload (plaintext)
2826
* @throws \ErrorException
2927
*/
30-
public static function padPayload(string $payload, int $maxLengthToPad, string $contentEncoding): string
28+
public static function padPayload(string $payload, int $maxLengthToPad, ContentEncoding $contentEncoding): string
3129
{
3230
$payloadLen = Utils::safeStrlen($payload);
3331
$padLen = $maxLengthToPad ? $maxLengthToPad - $payloadLen : 0;
3432

35-
if ($contentEncoding === "aesgcm") {
33+
if ($contentEncoding === ContentEncoding::aesgcm) {
3634
return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
3735
}
38-
if ($contentEncoding === "aes128gcm") {
36+
if ($contentEncoding === ContentEncoding::aes128gcm) {
3937
return str_pad($payload.chr(2), $padLen + $payloadLen, chr(0), STR_PAD_RIGHT);
4038
}
4139

42-
throw new \ErrorException("This content encoding is not supported");
40+
// @phpstan-ignore deadCode.unreachable
41+
throw new \ErrorException("This content encoding is not implemented.");
4342
}
4443

4544
/**
4645
* @param string $payload With padding
4746
* @param string $userPublicKey Base 64 encoded (MIME or URL-safe)
4847
* @param string $userAuthToken Base 64 encoded (MIME or URL-safe)
4948
*
50-
* @throws \ErrorException Thrown on php 8.1
5149
* @throws \Random\RandomException Thrown on php 8.2 and higher
5250
*/
5351
public static function encrypt(
5452
string $payload,
5553
string $userPublicKey,
5654
#[\SensitiveParameter]
5755
string $userAuthToken,
58-
string $contentEncoding,
56+
ContentEncoding $contentEncoding,
5957
): array {
6058
return self::deterministicEncrypt(
6159
$payload,
@@ -68,14 +66,14 @@ public static function encrypt(
6866
}
6967

7068
/**
71-
* @throws \RuntimeException
69+
* @throws \RuntimeException|\ErrorException
7270
*/
7371
public static function deterministicEncrypt(
7472
string $payload,
7573
string $userPublicKey,
7674
#[\SensitiveParameter]
7775
string $userAuthToken,
78-
string $contentEncoding,
76+
ContentEncoding $contentEncoding,
7977
array $localKeyObject,
8078
string $salt
8179
): array {
@@ -125,7 +123,7 @@ public static function deterministicEncrypt(
125123
$context = self::createContext($userPublicKey, $localPublicKey, $contentEncoding);
126124

127125
// derive the Content Encryption Key
128-
$contentEncryptionKeyInfo = self::createInfo($contentEncoding, $context, $contentEncoding);
126+
$contentEncryptionKeyInfo = self::createInfo($contentEncoding->value, $context, $contentEncoding);
129127
$contentEncryptionKey = self::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16);
130128

131129
// section 3.3, derive the nonce
@@ -145,16 +143,20 @@ public static function deterministicEncrypt(
145143
];
146144
}
147145

148-
public static function getContentCodingHeader(string $salt, string $localPublicKey, string $contentEncoding): string
146+
public static function getContentCodingHeader(string $salt, string $localPublicKey, ContentEncoding $contentEncoding): string
149147
{
150-
if ($contentEncoding === "aes128gcm") {
148+
if ($contentEncoding === ContentEncoding::aesgcm) {
149+
return "";
150+
}
151+
if ($contentEncoding === ContentEncoding::aes128gcm) {
151152
return $salt
152153
.pack('N*', 4096)
153154
.pack('C*', Utils::safeStrlen($localPublicKey))
154155
.$localPublicKey;
155156
}
156157

157-
return "";
158+
// @phpstan-ignore deadCode.unreachable
159+
throw new \ValueError("This content encoding is not implemented.");
158160
}
159161

160162
/**
@@ -195,19 +197,19 @@ private static function hkdf(string $salt, string $ikm, string $info, int $lengt
195197
*
196198
* @throws \ErrorException
197199
*/
198-
private static function createContext(string $clientPublicKey, string $serverPublicKey, string $contentEncoding): ?string
200+
private static function createContext(string $clientPublicKey, string $serverPublicKey, ContentEncoding $contentEncoding): ?string
199201
{
200-
if ($contentEncoding === "aes128gcm") {
202+
if ($contentEncoding === ContentEncoding::aes128gcm) {
201203
return null;
202204
}
203205

204206
if (Utils::safeStrlen($clientPublicKey) !== 65) {
205-
throw new \ErrorException('Invalid client public key length');
207+
throw new \ErrorException('Invalid client public key length.');
206208
}
207209

208210
// This one should never happen, because it's our code that generates the key
209211
if (Utils::safeStrlen($serverPublicKey) !== 65) {
210-
throw new \ErrorException('Invalid server public key length');
212+
throw new \ErrorException('Invalid server public key length.');
211213
}
212214

213215
$len = chr(0).'A'; // 65 as Uint16BE
@@ -225,25 +227,26 @@ private static function createContext(string $clientPublicKey, string $serverPub
225227
*
226228
* @throws \ErrorException
227229
*/
228-
private static function createInfo(string $type, ?string $context, string $contentEncoding): string
230+
private static function createInfo(string $type, ?string $context, ContentEncoding $contentEncoding): string
229231
{
230-
if ($contentEncoding === "aesgcm") {
232+
if ($contentEncoding === ContentEncoding::aesgcm) {
231233
if (!$context) {
232-
throw new \ErrorException('Context must exist');
234+
throw new \ValueError('Context must exist.');
233235
}
234236

235237
if (Utils::safeStrlen($context) !== 135) {
236-
throw new \ErrorException('Context argument has invalid size');
238+
throw new \ValueError('Context argument has invalid size.');
237239
}
238240

239241
return 'Content-Encoding: '.$type.chr(0).'P-256'.$context;
240242
}
241243

242-
if ($contentEncoding === "aes128gcm") {
244+
if ($contentEncoding === ContentEncoding::aes128gcm) {
243245
return 'Content-Encoding: '.$type.chr(0);
244246
}
245247

246-
throw new \ErrorException('This content encoding is not supported.');
248+
// @phpstan-ignore deadCode.unreachable
249+
throw new \ErrorException('This content encoding is not implemented.');
247250
}
248251

249252
private static function createLocalKeyObject(): array
@@ -275,17 +278,17 @@ private static function createLocalKeyObject(): array
275278
/**
276279
* @throws \ValueError
277280
*/
278-
private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, string $contentEncoding): string
281+
private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, ContentEncoding $contentEncoding): string
279282
{
280283
if (empty($userAuthToken)) {
281284
return $sharedSecret;
282285
}
283-
if ($contentEncoding === "aesgcm") {
286+
if ($contentEncoding === ContentEncoding::aesgcm) {
284287
$info = 'Content-Encoding: auth'.chr(0);
285-
} elseif ($contentEncoding === "aes128gcm") {
288+
} elseif ($contentEncoding === ContentEncoding::aes128gcm) {
286289
$info = "WebPush: info".chr(0).$userPublicKey.$localPublicKey;
287290
} else {
288-
throw new \ValueError("This content encoding is not supported.");
291+
throw new \ValueError("This content encoding is not implemented.");
289292
}
290293

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

src/Subscription.php

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,34 @@
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"
20-
* @throws \ErrorException
22+
* This is a data class. No key validation is done.
23+
* @param string|\Minishlink\WebPush\ContentEncoding|null $contentEncoding (Optional) defaults to "aesgcm". The next mayor will use "aes128gcm" as defined to rfc8291.
2124
*/
2225
public function __construct(
2326
private string $endpoint,
2427
private ?string $publicKey = null,
2528
private ?string $authToken = null,
26-
private ?string $contentEncoding = null
29+
ContentEncoding|string|null $contentEncoding = null,
2730
) {
2831
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.');
32+
if (is_string($contentEncoding)) {
33+
try {
34+
if (empty($contentEncoding)) {
35+
$contentEncoding = self::defaultContentEncoding;
36+
} else {
37+
$contentEncoding = ContentEncoding::from($contentEncoding);
38+
}
39+
} catch (\ValueError) {
40+
throw new \ValueError('This content encoding ('.$contentEncoding.') is not supported.');
41+
}
42+
} elseif ($contentEncoding === null) {
43+
$contentEncoding = self::defaultContentEncoding;
3244
}
33-
$this->contentEncoding = $contentEncoding ?: "aesgcm";
45+
$this->contentEncoding = $contentEncoding;
3446
}
3547
}
3648

@@ -45,7 +57,7 @@ public static function create(array $associativeArray): self
4557
$associativeArray['endpoint'],
4658
$associativeArray['keys']['p256dh'] ?? null,
4759
$associativeArray['keys']['auth'] ?? null,
48-
$associativeArray['contentEncoding'] ?? "aesgcm"
60+
$associativeArray['contentEncoding'] ?? ContentEncoding::aesgcm,
4961
);
5062
}
5163

@@ -54,7 +66,7 @@ public static function create(array $associativeArray): self
5466
$associativeArray['endpoint'],
5567
$associativeArray['publicKey'] ?? null,
5668
$associativeArray['authToken'] ?? null,
57-
$associativeArray['contentEncoding'] ?? "aesgcm"
69+
$associativeArray['contentEncoding'] ?? ContentEncoding::aesgcm,
5870
);
5971
}
6072

@@ -91,6 +103,11 @@ public function getAuthToken(): ?string
91103
* {@inheritDoc}
92104
*/
93105
public function getContentEncoding(): ?string
106+
{
107+
return $this->contentEncoding?->value;
108+
}
109+
110+
public function getContentEncodingTyped(): ?ContentEncoding
94111
{
95112
return $this->contentEncoding;
96113
}

src/VAPID.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public static function getVapidHeaders(
103103
string $publicKey,
104104
#[\SensitiveParameter]
105105
string $privateKey,
106-
string $contentEncoding,
106+
ContentEncoding $contentEncoding,
107107
?int $expiration = null,
108108
): array {
109109
$expirationLimit = time() + 43200; // equal margin of error between 0 and 24h
@@ -145,19 +145,20 @@ public static function getVapidHeaders(
145145
$jwt = $jwsCompactSerializer->serialize($jws, 0);
146146
$encodedPublicKey = Base64Url::encode($publicKey);
147147

148-
if ($contentEncoding === "aesgcm") {
148+
if ($contentEncoding === ContentEncoding::aesgcm) {
149149
return [
150150
'Authorization' => 'WebPush '.$jwt,
151151
'Crypto-Key' => 'p256ecdsa='.$encodedPublicKey,
152152
];
153153
}
154154

155-
if ($contentEncoding === 'aes128gcm') {
155+
if ($contentEncoding === ContentEncoding::aes128gcm) {
156156
return [
157157
'Authorization' => 'vapid t='.$jwt.', k='.$encodedPublicKey,
158158
];
159159
}
160160

161+
// @phpstan-ignore deadCode.unreachable
161162
throw new \ErrorException('This content encoding is not supported');
162163
}
163164

0 commit comments

Comments
 (0)