Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: convert contentEncoding to typesafe enum #423

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ $notifications = [
'p256dh' => '(stringOf88Chars)',
'auth' => '(stringOf24Chars)'
],
// key 'contentEncoding' is optional and defaults to Subscription::defaultContentEncoding
]),
'payload' => '{"message":"Hello World!"}',
], [
Expand Down
11 changes: 11 additions & 0 deletions src/ContentEncoding.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Minishlink\WebPush;

enum ContentEncoding: string
{
/** Outdated historic encoding. Was used by some browsers before rfc standard. Not recommended. */
case aesgcm = "aesgcm";
/** Defined in rfc8291. */
case aes128gcm = "aes128gcm";
}
59 changes: 34 additions & 25 deletions src/Encryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ class Encryption
* @return string padded payload (plaintext)
* @throws \ErrorException
*/
public static function padPayload(string $payload, int $maxLengthToPad, string $contentEncoding): string
public static function padPayload(string $payload, int $maxLengthToPad, ContentEncoding $contentEncoding): string
{
$payloadLen = Utils::safeStrlen($payload);
$padLen = $maxLengthToPad ? $maxLengthToPad - $payloadLen : 0;

if ($contentEncoding === "aesgcm") {
if ($contentEncoding === ContentEncoding::aesgcm) {
return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
}
if ($contentEncoding === "aes128gcm") {
if ($contentEncoding === ContentEncoding::aes128gcm) {
return str_pad($payload.chr(2), $padLen + $payloadLen, chr(0), STR_PAD_RIGHT);
}

throw new \ErrorException("This content encoding is not supported");
throw new \ErrorException("This content encoding is not implemented.");
}

/**
Expand All @@ -49,7 +49,7 @@ public static function padPayload(string $payload, int $maxLengthToPad, string $
*
* @throws \ErrorException
*/
public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding): array
public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken, ContentEncoding $contentEncoding): array
{
return self::deterministicEncrypt(
$payload,
Expand All @@ -64,8 +64,14 @@ public static function encrypt(string $payload, string $userPublicKey, string $u
/**
* @throws \RuntimeException
*/
public static function deterministicEncrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding, array $localKeyObject, string $salt): array
{
public static function deterministicEncrypt(
string $payload,
string $userPublicKey,
string $userAuthToken,
ContentEncoding $contentEncoding,
array $localKeyObject,
string $salt
): array {
$userPublicKey = Base64Url::decode($userPublicKey);
$userAuthToken = Base64Url::decode($userAuthToken);

Expand Down Expand Up @@ -112,7 +118,7 @@ public static function deterministicEncrypt(string $payload, string $userPublicK
$context = self::createContext($userPublicKey, $localPublicKey, $contentEncoding);

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

// section 3.3, derive the nonce
Expand All @@ -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.");
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 24 additions & 8 deletions src/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand All @@ -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,
);
}

Expand All @@ -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,
);
}

Expand Down Expand Up @@ -91,6 +102,11 @@ public function getAuthToken(): ?string
* {@inheritDoc}
*/
public function getContentEncoding(): ?string
{
return $this->contentEncoding?->value;
}

public function getContentEncodingTyped(): ?ContentEncoding
{
return $this->contentEncoding;
}
Expand Down
6 changes: 3 additions & 3 deletions src/VAPID.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
];
Expand Down
16 changes: 8 additions & 8 deletions src/WebPush.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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'];
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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];
}
Expand Down
7 changes: 4 additions & 3 deletions tests/EncryptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand All @@ -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));
Expand Down
Loading