Skip to content

Commit

Permalink
feat: convert contentEncoding to typesafe enum
Browse files Browse the repository at this point in the history
[BREAKING] change default encoding to aes128gcm
  • Loading branch information
Rotzbua committed Feb 6, 2024
1 parent 8d01790 commit e632f72
Show file tree
Hide file tree
Showing 10 changed files with 123 additions and 82 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -52,6 +52,7 @@ $notifications = [
'p256dh' => '(stringOf88Chars)',
'auth' => '(stringOf24Chars)',
],
// key 'contentEncoding' is optional and defaults to ContentEncoding::aes128gcm
]),
'payload' => '{"message":"Hello World!"}',
], [
Expand All @@ -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'],
Expand All @@ -77,7 +78,7 @@ foreach ($notifications as $notification) {
}

/**
* Check sent results
* Check sent results.
* @var MessageSentReport $report
*/
foreach ($webPush->flush() as $report) {
Expand All @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions src/ContentEncoding.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Minishlink\WebPush;

enum ContentEncoding: string
{
case aesgcm = "aesgcm";
case aes128gcm = "aes128gcm";
}
60 changes: 35 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: ".$contentEncoding);
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,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);
Expand Down
48 changes: 27 additions & 21 deletions src/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
}
Expand All @@ -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
Expand All @@ -76,7 +82,7 @@ public function getAuthToken(): string
return $this->authToken;
}

public function getContentEncoding(): string
public function getContentEncoding(): ContentEncoding
{
return $this->contentEncoding;
}
Expand Down
2 changes: 1 addition & 1 deletion src/SubscriptionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ public function getPublicKey(): string;

public function getAuthToken(): string;

public function getContentEncoding(): string;
public function getContentEncoding(): ContentEncoding;
}
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
20 changes: 6 additions & 14 deletions src/WebPush.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -193,21 +189,17 @@ 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'];
$localPublicKey = $encrypted['localPublicKey'];

$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);
}
Expand All @@ -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.');
Expand All @@ -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 {
Expand Down Expand Up @@ -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];
}
Expand Down
Loading

0 comments on commit e632f72

Please sign in to comment.