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 Mar 12, 2024
1 parent d947cd0 commit 59f2e6e
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 82 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ A complete example with html+JS frontend and php backend using `web-push-php` ca
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 @@ -65,6 +65,7 @@ $notifications = [
'p256dh' => '(stringOf88Chars)',
'auth' => '(stringOf24Chars)',
],
// key 'contentEncoding' is optional and defaults to ContentEncoding::aes128gcm
]),
'payload' => '{"message":"Hello World!"}',
], [
Expand All @@ -81,7 +82,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 @@ -90,7 +91,7 @@ foreach ($notifications as $notification) {
}

/**
* Check sent results
* Check sent results.
* @var MessageSentReport $report
*/
foreach ($webPush->flush() as $report) {
Expand All @@ -104,7 +105,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
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";
}
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 = Base64UrlSafe::decodeNoPadding($userPublicKey);
$userAuthToken = Base64UrlSafe::decodeNoPadding($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
49 changes: 28 additions & 21 deletions src/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,32 @@

class Subscription implements SubscriptionInterface
{
protected ContentEncoding $contentEncoding;
/**
* @param string $contentEncoding (Optional) defaults to "aesgcm"
* This is a data class. No key validation is done.
* @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 +56,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 +83,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 = Base64UrlSafe::encodeUnpadded($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
Loading

0 comments on commit 59f2e6e

Please sign in to comment.