From e632f72bfdea593c8fa109cca4be2854d139622e Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Tue, 6 Feb 2024 19:20:48 +0100 Subject: [PATCH] feat: convert contentEncoding to typesafe enum [BREAKING] change default encoding to aes128gcm --- README.md | 11 ++++--- src/ContentEncoding.php | 9 ++++++ src/Encryption.php | 60 ++++++++++++++++++++--------------- src/Subscription.php | 48 ++++++++++++++++------------ src/SubscriptionInterface.php | 2 +- src/VAPID.php | 6 ++-- src/WebPush.php | 20 ++++-------- tests/EncryptionTest.php | 7 ++-- tests/SubscriptionTest.php | 35 ++++++++++++++++---- tests/VAPIDTest.php | 7 ++-- 10 files changed, 123 insertions(+), 82 deletions(-) create mode 100644 src/ContentEncoding.php diff --git a/README.md b/README.md index e4a9b00..75aa262 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,10 @@ Use [composer](https://getcomposer.org/) to download and install the library and use Minishlink\WebPush\WebPush; use Minishlink\WebPush\Subscription; -// store the client-side `PushSubscription` object (calling `.toJSON` on it) as-is and then create a WebPush\Subscription from it +// Store the client-side `PushSubscription` object (calling `.toJSON` on it) as-is and then create a WebPush\Subscription from it. $subscription = Subscription::create(json_decode($clientSidePushSubscriptionJSON, true)); -// array of notifications +// Array of push messages. $notifications = [ [ 'subscription' => $subscription, @@ -52,6 +52,7 @@ $notifications = [ 'p256dh' => '(stringOf88Chars)', 'auth' => '(stringOf24Chars)', ], + // key 'contentEncoding' is optional and defaults to ContentEncoding::aes128gcm ]), 'payload' => '{"message":"Hello World!"}', ], [ @@ -68,7 +69,7 @@ $notifications = [ $webPush = new WebPush(); -// send multiple notifications with payload +// Send multiple push messages with payload. foreach ($notifications as $notification) { $webPush->queueNotification( $notification['subscription'], @@ -77,7 +78,7 @@ foreach ($notifications as $notification) { } /** - * Check sent results + * Check sent results. * @var MessageSentReport $report */ foreach ($webPush->flush() as $report) { @@ -91,7 +92,7 @@ foreach ($webPush->flush() as $report) { } /** - * send one notification and flush directly + * Send one push message and flush directly. * @var MessageSentReport $report */ $report = $webPush->sendOneNotification( diff --git a/src/ContentEncoding.php b/src/ContentEncoding.php new file mode 100644 index 0000000..c4f00b5 --- /dev/null +++ b/src/ContentEncoding.php @@ -0,0 +1,9 @@ +value, $context, $contentEncoding); $contentEncryptionKey = self::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16); // section 3.3, derive the nonce @@ -132,16 +138,19 @@ public static function deterministicEncrypt(string $payload, string $userPublicK ]; } - public static function getContentCodingHeader(string $salt, string $localPublicKey, string $contentEncoding): string + public static function getContentCodingHeader(string $salt, string $localPublicKey, ContentEncoding $contentEncoding): string { - if ($contentEncoding === "aes128gcm") { + if ($contentEncoding === ContentEncoding::aesgcm) { + return ""; + } + if ($contentEncoding === ContentEncoding::aes128gcm) { return $salt .pack('N*', 4096) .pack('C*', Utils::safeStrlen($localPublicKey)) .$localPublicKey; } - return ""; + throw new \ValueError("This content encoding is not implemented."); } /** @@ -182,19 +191,19 @@ private static function hkdf(string $salt, string $ikm, string $info, int $lengt * * @throws \ErrorException */ - private static function createContext(string $clientPublicKey, string $serverPublicKey, string $contentEncoding): ?string + private static function createContext(string $clientPublicKey, string $serverPublicKey, ContentEncoding $contentEncoding): ?string { - if ($contentEncoding === "aes128gcm") { + if ($contentEncoding === ContentEncoding::aes128gcm) { return null; } if (Utils::safeStrlen($clientPublicKey) !== 65) { - throw new \ErrorException('Invalid client public key length'); + throw new \ErrorException('Invalid client public key length.'); } // This one should never happen, because it's our code that generates the key if (Utils::safeStrlen($serverPublicKey) !== 65) { - throw new \ErrorException('Invalid server public key length'); + throw new \ErrorException('Invalid server public key length.'); } $len = chr(0).'A'; // 65 as Uint16BE @@ -212,25 +221,25 @@ private static function createContext(string $clientPublicKey, string $serverPub * * @throws \ErrorException */ - private static function createInfo(string $type, ?string $context, string $contentEncoding): string + private static function createInfo(string $type, ?string $context, ContentEncoding $contentEncoding): string { - if ($contentEncoding === "aesgcm") { + if ($contentEncoding === ContentEncoding::aesgcm) { if (!$context) { - throw new \ErrorException('Context must exist'); + throw new \ValueError('Context must exist.'); } if (Utils::safeStrlen($context) !== 135) { - throw new \ErrorException('Context argument has invalid size'); + throw new \ValueError('Context argument has invalid size.'); } return 'Content-Encoding: '.$type.chr(0).'P-256'.$context; } - if ($contentEncoding === "aes128gcm") { + if ($contentEncoding === ContentEncoding::aes128gcm) { return 'Content-Encoding: '.$type.chr(0); } - throw new \ErrorException('This content encoding is not supported.'); + throw new \ErrorException('This content encoding is not implemented.'); } private static function createLocalKeyObject(): array @@ -262,17 +271,18 @@ private static function createLocalKeyObject(): array /** * @throws \ValueError */ - private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, string $contentEncoding): string + private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, ContentEncoding $contentEncoding): string { if (empty($userAuthToken)) { return $sharedSecret; } - if($contentEncoding === "aesgcm") { + + if ($contentEncoding === ContentEncoding::aesgcm) { $info = 'Content-Encoding: auth'.chr(0); - } elseif($contentEncoding === "aes128gcm") { + } elseif ($contentEncoding === ContentEncoding::aes128gcm) { $info = "WebPush: info".chr(0).$userPublicKey.$localPublicKey; } else { - throw new \ValueError("This content encoding is not supported."); + throw new \ValueError("This content encoding is not implemented."); } return self::hkdf($userAuthToken, $sharedSecret, $info, 32); diff --git a/src/Subscription.php b/src/Subscription.php index a01fd53..60e395d 100644 --- a/src/Subscription.php +++ b/src/Subscription.php @@ -15,21 +15,31 @@ class Subscription implements SubscriptionInterface { + protected ContentEncoding $contentEncoding; /** - * @param string $contentEncoding (Optional) defaults to "aesgcm" + * @param string|\Minishlink\WebPush\ContentEncoding $contentEncoding (Optional) defaults to "aes128gcm" as defined to rfc8291. * @throws \ErrorException */ public function __construct( - private readonly string $endpoint, - private readonly string $publicKey, - private readonly string $authToken, - private readonly string $contentEncoding = "aesgcm", + protected readonly string $endpoint, + protected readonly string $publicKey, + protected readonly string $authToken, + ContentEncoding|string $contentEncoding = ContentEncoding::aes128gcm, ) { - $supportedContentEncodings = ['aesgcm', 'aes128gcm']; - if ($contentEncoding && !in_array($contentEncoding, $supportedContentEncodings, true)) { - throw new \ErrorException('This content encoding ('.$contentEncoding.') is not supported.'); + if(is_string($contentEncoding)) { + try { + if(empty($contentEncoding)) { + $this->contentEncoding = ContentEncoding::aesgcm; // default + } else { + $this->contentEncoding = ContentEncoding::from($contentEncoding); + } + } catch(\ValueError) { + throw new \ValueError('This content encoding ('.$contentEncoding.') is not supported.'); + } + } else { + $this->contentEncoding = $contentEncoding; } - if(empty($publicKey) || empty($authToken) || empty($contentEncoding)) { + if(empty($publicKey) || empty($authToken)) { throw new \ValueError('Missing values.'); } } @@ -45,20 +55,16 @@ public static function create(array $associativeArray): self $associativeArray['endpoint'] ?? "", $associativeArray['keys']['p256dh'] ?? "", $associativeArray['keys']['auth'] ?? "", - $associativeArray['contentEncoding'] ?? "aesgcm" + $associativeArray['contentEncoding'] ?? ContentEncoding::aes128gcm, ); } - if (array_key_exists('publicKey', $associativeArray) || array_key_exists('authToken', $associativeArray) || array_key_exists('contentEncoding', $associativeArray)) { - return new self( - $associativeArray['endpoint'] ?? "", - $associativeArray['publicKey'] ?? "", - $associativeArray['authToken'] ?? "", - $associativeArray['contentEncoding'] ?? "aesgcm" - ); - } - - throw new \ValueError('Missing values.'); + return new self( + $associativeArray['endpoint'] ?? "", + $associativeArray['publicKey'] ?? "", + $associativeArray['authToken'] ?? "", + $associativeArray['contentEncoding'] ?? ContentEncoding::aes128gcm, + ); } public function getEndpoint(): string @@ -76,7 +82,7 @@ public function getAuthToken(): string return $this->authToken; } - public function getContentEncoding(): string + public function getContentEncoding(): ContentEncoding { return $this->contentEncoding; } diff --git a/src/SubscriptionInterface.php b/src/SubscriptionInterface.php index 0b60edd..8cbe8f7 100644 --- a/src/SubscriptionInterface.php +++ b/src/SubscriptionInterface.php @@ -25,5 +25,5 @@ public function getPublicKey(): string; public function getAuthToken(): string; - public function getContentEncoding(): string; + public function getContentEncoding(): ContentEncoding; } diff --git a/src/VAPID.php b/src/VAPID.php index 5a40cd9..0cf937e 100644 --- a/src/VAPID.php +++ b/src/VAPID.php @@ -97,7 +97,7 @@ public static function validate(array $vapid): array * @return array Returns an array with the 'Authorization' and 'Crypto-Key' values to be used as headers * @throws \ErrorException */ - public static function getVapidHeaders(string $audience, string $subject, string $publicKey, string $privateKey, string $contentEncoding, ?int $expiration = null): array + public static function getVapidHeaders(string $audience, string $subject, string $publicKey, string $privateKey, ContentEncoding $contentEncoding, ?int $expiration = null): array { $expirationLimit = time() + 43200; // equal margin of error between 0 and 24h if (null === $expiration || $expiration > $expirationLimit) { @@ -138,14 +138,14 @@ public static function getVapidHeaders(string $audience, string $subject, string $jwt = $jwsCompactSerializer->serialize($jws, 0); $encodedPublicKey = Base64Url::encode($publicKey); - if ($contentEncoding === "aesgcm") { + if ($contentEncoding === ContentEncoding::aesgcm) { return [ 'Authorization' => 'WebPush '.$jwt, 'Crypto-Key' => 'p256ecdsa='.$encodedPublicKey, ]; } - if ($contentEncoding === 'aes128gcm') { + if ($contentEncoding === ContentEncoding::aes128gcm) { return [ 'Authorization' => 'vapid t='.$jwt.', k='.$encodedPublicKey, ]; diff --git a/src/WebPush.php b/src/WebPush.php index 68de138..726b7a5 100644 --- a/src/WebPush.php +++ b/src/WebPush.php @@ -92,10 +92,6 @@ public function queueNotification(SubscriptionInterface $subscription, ?string $ } $contentEncoding = $subscription->getContentEncoding(); - if (!$contentEncoding) { - throw new \ErrorException('Subscription should have a content encoding'); - } - $payload = Encryption::padPayload($payload, $this->automaticPadding, $contentEncoding); } @@ -193,10 +189,6 @@ protected function prepare(array $notifications): array $auth = $notification->getAuth($this->auth); if (!empty($payload) && !empty($userPublicKey) && !empty($userAuthToken)) { - if (!$contentEncoding) { - throw new \ErrorException('Subscription should have a content encoding'); - } - $encrypted = Encryption::encrypt($payload, $userPublicKey, $userAuthToken, $contentEncoding); $cipherText = $encrypted['cipherText']; $salt = $encrypted['salt']; @@ -204,10 +196,10 @@ protected function prepare(array $notifications): array $headers = [ 'Content-Type' => 'application/octet-stream', - 'Content-Encoding' => $contentEncoding, + 'Content-Encoding' => $contentEncoding->value, ]; - if ($contentEncoding === "aesgcm") { + if ($contentEncoding === ContentEncoding::aesgcm) { $headers['Encryption'] = 'salt='.Base64Url::encode($salt); $headers['Crypto-Key'] = 'dh='.Base64Url::encode($localPublicKey); } @@ -234,7 +226,7 @@ protected function prepare(array $notifications): array $headers['Topic'] = $options['topic']; } - if (array_key_exists('VAPID', $auth) && $contentEncoding) { + if (array_key_exists('VAPID', $auth)) { $audience = parse_url($endpoint, PHP_URL_SCHEME).'://'.parse_url($endpoint, PHP_URL_HOST); if (!parse_url($audience)) { throw new \ErrorException('Audience "'.$audience.'"" could not be generated.'); @@ -244,7 +236,7 @@ protected function prepare(array $notifications): array $headers['Authorization'] = $vapidHeaders['Authorization']; - if ($contentEncoding === 'aesgcm') { + if ($contentEncoding === ContentEncoding::aesgcm) { if (array_key_exists('Crypto-Key', $headers)) { $headers['Crypto-Key'] .= ';'.$vapidHeaders['Crypto-Key']; } else { @@ -335,13 +327,13 @@ public function countPendingNotifications(): int /** * @throws \ErrorException */ - protected function getVAPIDHeaders(string $audience, string $contentEncoding, array $vapid): ?array + protected function getVAPIDHeaders(string $audience, ContentEncoding $contentEncoding, array $vapid): ?array { $vapidHeaders = null; $cache_key = null; if ($this->reuseVAPIDHeaders) { - $cache_key = implode('#', [$audience, $contentEncoding, crc32(serialize($vapid))]); + $cache_key = implode('#', [$audience, $contentEncoding->value, crc32(serialize($vapid))]); if (array_key_exists($cache_key, $this->vapidHeaders)) { $vapidHeaders = $this->vapidHeaders[$cache_key]; } diff --git a/tests/EncryptionTest.php b/tests/EncryptionTest.php index 68e1ca9..29e466c 100644 --- a/tests/EncryptionTest.php +++ b/tests/EncryptionTest.php @@ -10,6 +10,7 @@ use Base64Url\Base64Url; use Jose\Component\Core\JWK; +use Minishlink\WebPush\ContentEncoding; use Minishlink\WebPush\Encryption; use Minishlink\WebPush\Utils; use PHPUnit\Framework\Attributes\DataProvider; @@ -21,7 +22,7 @@ final class EncryptionTest extends PHPUnit\Framework\TestCase { public function testDeterministicEncrypt(): void { - $contentEncoding = "aes128gcm"; + $contentEncoding = ContentEncoding::aes128gcm; $plaintext = 'When I grow up, I want to be a watermelon'; $this->assertEquals('V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24', Base64Url::encode($plaintext)); @@ -68,7 +69,7 @@ public function testGetContentCodingHeader(): void $localPublicKey = Base64Url::decode('BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8'); $salt = Base64Url::decode('DGv6ra1nlYgDCS1FRnbzlw'); - $result = Encryption::getContentCodingHeader($salt, $localPublicKey, "aes128gcm"); + $result = Encryption::getContentCodingHeader($salt, $localPublicKey, ContentEncoding::aes128gcm); $expected = Base64Url::decode('DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8'); $this->assertEquals(Utils::safeStrlen($expected), Utils::safeStrlen($result)); @@ -81,7 +82,7 @@ public function testGetContentCodingHeader(): void #[dataProvider('payloadProvider')] public function testPadPayload(string $payload, int $maxLengthToPad, int $expectedResLength): void { - $res = Encryption::padPayload($payload, $maxLengthToPad, "aesgcm"); + $res = Encryption::padPayload($payload, $maxLengthToPad, ContentEncoding::aesgcm); $this->assertStringContainsString('test', $res); $this->assertEquals($expectedResLength, Utils::safeStrlen($res)); diff --git a/tests/SubscriptionTest.php b/tests/SubscriptionTest.php index d891a5f..2fe50e9 100644 --- a/tests/SubscriptionTest.php +++ b/tests/SubscriptionTest.php @@ -1,5 +1,6 @@ assertEquals("http://toto.com", $subscription->getEndpoint()); $this->assertEquals("publicKey", $subscription->getPublicKey()); $this->assertEquals("authToken", $subscription->getAuthToken()); - $this->assertEquals("aesgcm", $subscription->getContentEncoding()); + $this->assertEquals(ContentEncoding::aes128gcm, $subscription->getContentEncoding()); } public function testConstructPartial(): void @@ -73,11 +74,24 @@ public function testConstructPartial(): void $this->assertEquals("http://toto.com", $subscription->getEndpoint()); $this->assertEquals("publicKey", $subscription->getPublicKey()); $this->assertEquals("authToken", $subscription->getAuthToken()); - $this->assertEquals("aesgcm", $subscription->getContentEncoding()); + $this->assertEquals(ContentEncoding::aes128gcm, $subscription->getContentEncoding()); } public function testCreateFull(): void { + $subscriptionArray = [ + "endpoint" => "http://toto.com", + "publicKey" => "publicKey", + "authToken" => "authToken", + "contentEncoding" => ContentEncoding::aes128gcm, + ]; + $subscription = Subscription::create($subscriptionArray); + $this->assertEquals("http://toto.com", $subscription->getEndpoint()); + $this->assertEquals("publicKey", $subscription->getPublicKey()); + $this->assertEquals("authToken", $subscription->getAuthToken()); + $this->assertEquals(ContentEncoding::aes128gcm, $subscription->getContentEncoding()); + + // Test with type string contentEncoding $subscriptionArray = [ "endpoint" => "http://toto.com", "publicKey" => "publicKey", @@ -88,18 +102,24 @@ public function testCreateFull(): void $this->assertEquals("http://toto.com", $subscription->getEndpoint()); $this->assertEquals("publicKey", $subscription->getPublicKey()); $this->assertEquals("authToken", $subscription->getAuthToken()); - $this->assertEquals("aes128gcm", $subscription->getContentEncoding()); + $this->assertEquals(ContentEncoding::aes128gcm, $subscription->getContentEncoding()); } public function testConstructFull(): void { - $subscription = new Subscription("http://toto.com", "publicKey", "authToken", "aes128gcm"); + $subscription = new Subscription("http://toto.com", "publicKey", "authToken", ContentEncoding::aes128gcm); $this->assertEquals("http://toto.com", $subscription->getEndpoint()); $this->assertEquals("publicKey", $subscription->getPublicKey()); $this->assertEquals("authToken", $subscription->getAuthToken()); - $this->assertEquals("aes128gcm", $subscription->getContentEncoding()); - } + $this->assertEquals(ContentEncoding::aes128gcm, $subscription->getContentEncoding()); + // Test with type string contentEncoding + $subscription = new Subscription("http://toto.com", "publicKey", "authToken", "aesgcm"); + $this->assertEquals("http://toto.com", $subscription->getEndpoint()); + $this->assertEquals("publicKey", $subscription->getPublicKey()); + $this->assertEquals("authToken", $subscription->getAuthToken()); + $this->assertEquals(ContentEncoding::aesgcm, $subscription->getContentEncoding()); + } public function testCreatePartialWithNewStructure(): void { $subscription = Subscription::create([ @@ -112,6 +132,7 @@ public function testCreatePartialWithNewStructure(): void $this->assertEquals("http://toto.com", $subscription->getEndpoint()); $this->assertEquals("publicKey", $subscription->getPublicKey()); $this->assertEquals("authToken", $subscription->getAuthToken()); + $this->assertEquals(ContentEncoding::aes128gcm, $subscription->getContentEncoding()); } public function testCreatePartialWithNewStructureAndContentEncoding(): void @@ -127,6 +148,6 @@ public function testCreatePartialWithNewStructureAndContentEncoding(): void $this->assertEquals("http://toto.com", $subscription->getEndpoint()); $this->assertEquals("publicKey", $subscription->getPublicKey()); $this->assertEquals("authToken", $subscription->getAuthToken()); - $this->assertEquals("aes128gcm", $subscription->getContentEncoding()); + $this->assertEquals(ContentEncoding::aes128gcm, $subscription->getContentEncoding()); } } diff --git a/tests/VAPIDTest.php b/tests/VAPIDTest.php index b28f99b..40986f8 100644 --- a/tests/VAPIDTest.php +++ b/tests/VAPIDTest.php @@ -8,6 +8,7 @@ * file that was distributed with this source code. */ +use Minishlink\WebPush\ContentEncoding; use Minishlink\WebPush\Utils; use Minishlink\WebPush\VAPID; use PHPUnit\Framework\Attributes\DataProvider; @@ -27,7 +28,7 @@ public static function vapidProvider(): array 'publicKey' => 'BA6jvk34k6YjElHQ6S0oZwmrsqHdCNajxcod6KJnI77Dagikfb--O_kYXcR2eflRz6l3PcI2r8fPCH3BElLQHDk', 'privateKey' => '-3CdhFOqjzixgAbUSa0Zv9zi-dwDVmWO7672aBxSFPQ', ], - "aesgcm", + ContentEncoding::aesgcm, 1475452165, 'WebPush eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwOi8vcHVzaC5jb20iLCJleHAiOjE0NzU0NTIxNjUsInN1YiI6Imh0dHA6Ly90ZXN0LmNvbSJ9.4F3ZKjeru4P9XM20rHPNvGBcr9zxhz8_ViyNfe11_xcuy7A9y7KfEPt6yuNikyW7eT9zYYD5mQZubDGa-5H2cA', 'p256ecdsa=BA6jvk34k6YjElHQ6S0oZwmrsqHdCNajxcod6KJnI77Dagikfb--O_kYXcR2eflRz6l3PcI2r8fPCH3BElLQHDk', @@ -38,7 +39,7 @@ public static function vapidProvider(): array 'publicKey' => 'BA6jvk34k6YjElHQ6S0oZwmrsqHdCNajxcod6KJnI77Dagikfb--O_kYXcR2eflRz6l3PcI2r8fPCH3BElLQHDk', 'privateKey' => '-3CdhFOqjzixgAbUSa0Zv9zi-dwDVmWO7672aBxSFPQ', ], - "aes128gcm", + ContentEncoding::aes128gcm, 1475452165, 'vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwOi8vcHVzaC5jb20iLCJleHAiOjE0NzU0NTIxNjUsInN1YiI6Imh0dHA6Ly90ZXN0LmNvbSJ9.4F3ZKjeru4P9XM20rHPNvGBcr9zxhz8_ViyNfe11_xcuy7A9y7KfEPt6yuNikyW7eT9zYYD5mQZubDGa-5H2cA, k=BA6jvk34k6YjElHQ6S0oZwmrsqHdCNajxcod6KJnI77Dagikfb--O_kYXcR2eflRz6l3PcI2r8fPCH3BElLQHDk', null, @@ -50,7 +51,7 @@ public static function vapidProvider(): array * @throws ErrorException */ #[dataProvider('vapidProvider')] - public function testGetVapidHeaders(string $audience, array $vapid, string $contentEncoding, int $expiration, string $expectedAuthorization, ?string $expectedCryptoKey): void + public function testGetVapidHeaders(string $audience, array $vapid, ContentEncoding $contentEncoding, int $expiration, string $expectedAuthorization, ?string $expectedCryptoKey): void { $vapid = VAPID::validate($vapid); $headers = VAPID::getVapidHeaders(