diff --git a/README.md b/README.md index 978e6b2..93a8cc5 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ $notifications = [ 'p256dh' => '(stringOf88Chars)', 'auth' => '(stringOf24Chars)' ], + // key 'contentEncoding' is optional and defaults to Subscription::defaultContentEncoding ]), 'payload' => '{"message":"Hello World!"}', ], [ diff --git a/src/ContentEncoding.php b/src/ContentEncoding.php new file mode 100644 index 0000000..b525608 --- /dev/null +++ b/src/ContentEncoding.php @@ -0,0 +1,11 @@ +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,17 @@ 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 571731a..022368d 100644 --- a/src/Subscription.php +++ b/src/Subscription.php @@ -15,22 +15,33 @@ class Subscription implements SubscriptionInterface { + public const defaultContentEncoding = ContentEncoding::aesgcm; // Default for legacy input. The next mayor will use "aes128gcm" as defined to rfc8291. + protected ?ContentEncoding $contentEncoding = null; + /** - * @param string|null $contentEncoding (Optional) Must be "aesgcm" + * This is a data class. No key validation is done. + * @param string|\Minishlink\WebPush\ContentEncoding $contentEncoding (Optional) defaults to "aesgcm". The next mayor will use "aes128gcm" as defined to rfc8291. * @throws \ErrorException */ public function __construct( private string $endpoint, private ?string $publicKey = null, private ?string $authToken = null, - private ?string $contentEncoding = null + ContentEncoding|string|null $contentEncoding = null, ) { if ($publicKey || $authToken || $contentEncoding) { - $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)) { + $contentEncoding = self::defaultContentEncoding; + } else { + $contentEncoding = ContentEncoding::from($contentEncoding); + } + } catch (\ValueError) { + throw new \ValueError('This content encoding ('.$contentEncoding.') is not supported.'); + } } - $this->contentEncoding = $contentEncoding ?: "aesgcm"; + $this->contentEncoding = $contentEncoding ?: ContentEncoding::aesgcm; } } @@ -45,7 +56,7 @@ public static function create(array $associativeArray): self $associativeArray['endpoint'], $associativeArray['keys']['p256dh'] ?? null, $associativeArray['keys']['auth'] ?? null, - $associativeArray['contentEncoding'] ?? "aesgcm" + $associativeArray['contentEncoding'] ?? ContentEncoding::aesgcm, ); } @@ -54,7 +65,7 @@ public static function create(array $associativeArray): self $associativeArray['endpoint'], $associativeArray['publicKey'] ?? null, $associativeArray['authToken'] ?? null, - $associativeArray['contentEncoding'] ?? "aesgcm" + $associativeArray['contentEncoding'] ?? ContentEncoding::aesgcm, ); } @@ -91,6 +102,11 @@ public function getAuthToken(): ?string * {@inheritDoc} */ public function getContentEncoding(): ?string + { + return $this->contentEncoding?->value; + } + + public function getContentEncodingTyped(): ?ContentEncoding { return $this->contentEncoding; } diff --git a/src/VAPID.php b/src/VAPID.php index 9e9e424..87a4e2a 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 e470b6e..86a8b9c 100644 --- a/src/WebPush.php +++ b/src/WebPush.php @@ -99,7 +99,7 @@ public function queueNotification(SubscriptionInterface $subscription, ?string $ throw new \ErrorException('Subscription should have a content encoding'); } - $payload = Encryption::padPayload($payload, $this->automaticPadding, $contentEncoding); + $payload = Encryption::padPayload($payload, $this->automaticPadding, ContentEncoding::from($contentEncoding)); } if (array_key_exists('VAPID', $auth)) { @@ -257,7 +257,7 @@ protected function prepare(array $notifications): array throw new \ErrorException('Subscription should have a content encoding'); } - $encrypted = Encryption::encrypt($payload, $userPublicKey, $userAuthToken, $contentEncoding); + $encrypted = Encryption::encrypt($payload, $userPublicKey, $userAuthToken, ContentEncoding::from($contentEncoding)); $cipherText = $encrypted['cipherText']; $salt = $encrypted['salt']; $localPublicKey = $encrypted['localPublicKey']; @@ -267,12 +267,12 @@ protected function prepare(array $notifications): array 'Content-Encoding' => $contentEncoding, ]; - if ($contentEncoding === "aesgcm") { + if ($contentEncoding === ContentEncoding::aesgcm->value) { $headers['Encryption'] = 'salt='.Base64Url::encode($salt); $headers['Crypto-Key'] = 'dh='.Base64Url::encode($localPublicKey); } - $encryptionContentCodingHeader = Encryption::getContentCodingHeader($salt, $localPublicKey, $contentEncoding); + $encryptionContentCodingHeader = Encryption::getContentCodingHeader($salt, $localPublicKey, ContentEncoding::from($contentEncoding)); $content = $encryptionContentCodingHeader.$cipherText; $headers['Content-Length'] = (string) Utils::safeStrlen($content); @@ -300,11 +300,11 @@ protected function prepare(array $notifications): array throw new \ErrorException('Audience "'.$audience.'"" could not be generated.'); } - $vapidHeaders = $this->getVAPIDHeaders($audience, $contentEncoding, $auth['VAPID']); + $vapidHeaders = $this->getVAPIDHeaders($audience, ContentEncoding::from($contentEncoding), $auth['VAPID']); $headers['Authorization'] = $vapidHeaders['Authorization']; - if ($contentEncoding === 'aesgcm') { + if ($contentEncoding === ContentEncoding::aesgcm->value) { if (array_key_exists('Crypto-Key', $headers)) { $headers['Crypto-Key'] .= ';'.$vapidHeaders['Crypto-Key']; } else { @@ -397,13 +397,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 13cd849..41db761 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\CoversClass; @@ -37,7 +38,7 @@ public function testBase64Decode(): void public function testDeterministicEncrypt(): void { - $contentEncoding = 'aes128gcm'; + $contentEncoding = ContentEncoding::aes128gcm; $plaintext = 'When I grow up, I want to be a watermelon'; $payload = Encryption::padPayload($plaintext, 0, $contentEncoding); @@ -83,7 +84,7 @@ public function testGetContentCodingHeader(): void $localPublicKey = $this->base64Decode('BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8'); $salt = $this->base64Decode('DGv6ra1nlYgDCS1FRnbzlw'); - $result = Encryption::getContentCodingHeader($salt, $localPublicKey, "aes128gcm"); + $result = Encryption::getContentCodingHeader($salt, $localPublicKey, ContentEncoding::aes128gcm); $expected = $this->base64Decode('DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8'); $this->assertEquals(Utils::safeStrlen($expected), Utils::safeStrlen($result)); @@ -96,7 +97,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 e57c3b5..1a8741a 100644 --- a/tests/SubscriptionTest.php +++ b/tests/SubscriptionTest.php @@ -1,5 +1,6 @@ assertNull($subscription->getPublicKey()); $this->assertNull($subscription->getAuthToken()); $this->assertNull($subscription->getContentEncoding()); + $this->assertNull($subscription->getContentEncodingTyped()); } public function testConstructMinimal(): void @@ -25,6 +27,7 @@ public function testConstructMinimal(): void $this->assertNull($subscription->getPublicKey()); $this->assertNull($subscription->getAuthToken()); $this->assertNull($subscription->getContentEncoding()); + $this->assertNull($subscription->getContentEncodingTyped()); } public function testCreatePartial(): void @@ -39,6 +42,7 @@ public function testCreatePartial(): void $this->assertEquals("publicKey", $subscription->getPublicKey()); $this->assertEquals("authToken", $subscription->getAuthToken()); $this->assertEquals("aesgcm", $subscription->getContentEncoding()); + $this->assertSame(ContentEncoding::aesgcm, $subscription->getContentEncodingTyped()); } public function testConstructPartial(): void @@ -47,11 +51,26 @@ 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->assertSame("aesgcm", $subscription->getContentEncoding()); + $this->assertSame(ContentEncoding::aesgcm, $subscription->getContentEncodingTyped()); } 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->assertSame("aes128gcm", $subscription->getContentEncoding()); + $this->assertSame(ContentEncoding::aes128gcm, $subscription->getContentEncodingTyped()); + + // Test with type string contentEncoding $subscriptionArray = [ "endpoint" => "http://toto.com", "publicKey" => "publicKey", @@ -62,18 +81,27 @@ 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->assertSame("aes128gcm", $subscription->getContentEncoding()); + $this->assertSame(ContentEncoding::aes128gcm, $subscription->getContentEncodingTyped()); } 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->assertSame("aes128gcm", $subscription->getContentEncoding()); + $this->assertSame(ContentEncoding::aes128gcm, $subscription->getContentEncodingTyped()); + // 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->assertSame("aesgcm", $subscription->getContentEncoding()); + $this->assertSame(ContentEncoding::aesgcm, $subscription->getContentEncodingTyped()); + } public function testCreatePartialWithNewStructure(): void { $subscription = Subscription::create([ @@ -86,6 +114,8 @@ public function testCreatePartialWithNewStructure(): void $this->assertEquals("http://toto.com", $subscription->getEndpoint()); $this->assertEquals("publicKey", $subscription->getPublicKey()); $this->assertEquals("authToken", $subscription->getAuthToken()); + $this->assertSame("aesgcm", $subscription->getContentEncoding()); + $this->assertSame(ContentEncoding::aesgcm, $subscription->getContentEncodingTyped()); } public function testCreatePartialWithNewStructureAndContentEncoding(): void @@ -101,6 +131,7 @@ 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->assertSame("aes128gcm", $subscription->getContentEncoding()); + $this->assertSame(ContentEncoding::aes128gcm, $subscription->getContentEncodingTyped()); } } diff --git a/tests/VAPIDTest.php b/tests/VAPIDTest.php index 759de8e..0be242b 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\CoversClass; @@ -26,7 +27,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', @@ -37,7 +38,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, @@ -49,7 +50,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(