Skip to content
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
3 changes: 2 additions & 1 deletion .php-cs-fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
'@PSR12' => true, // The default rule.
'@autoPHPMigration' => true, // Uses min PHP version for regular migrations.
'blank_line_after_opening_tag' => false, // Do not waste space between <?php and declare.
'declare_strict_types' => true,
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
'php_unit_construct' => true,
'php_unit_attributes' => true,
'php_unit_construct' => true,
'php_unit_method_casing' => true,
'php_unit_test_class_requires_covers' => true,
// Do not enable by default. These rules require review!! (but they are useful)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ $notifications = [
'p256dh' => '(stringOf88Chars)',
'auth' => '(stringOf24Chars)'
],
// key 'contentEncoding' is optional and defaults to Subscription::defaultContentEncoding
]),
'payload' => '{"message":"Hello World!"}',
], [
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"phpunit/phpunit": "^11.5.46|^12.5.2",
"phpstan/phpstan": "^2.1.33",
"friendsofphp/php-cs-fixer": "^v3.91.3",
"symfony/polyfill-iconv": "^1.33"
"symfony/polyfill-iconv": "^1.33",
"phpstan/phpstan-strict-rules": "^2.0"
},
"autoload": {
"psr-4": {
Expand Down
6 changes: 6 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ parameters:
reportUnmatchedIgnoredErrors: false
ignoreErrors:
- identifier: missingType.iterableValue
strictRules:
booleansInConditions: false
disallowedEmpty: false

includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
11 changes: 11 additions & 0 deletions src/ContentEncoding.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php declare(strict_types=1);

namespace Minishlink\WebPush;

enum ContentEncoding: string
{
/** Not recommended. Outdated historic encoding. Was used by some browsers before rfc standard. */
case aesgcm = "aesgcm";
/** Defined in rfc8291. */
case aes128gcm = "aes128gcm";
}
61 changes: 32 additions & 29 deletions src/Encryption.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
<?php

declare(strict_types=1);
<?php declare(strict_types=1);

/*
* This file is part of the WebPush library.
Expand All @@ -27,35 +25,35 @@ 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");
// @phpstan-ignore deadCode.unreachable
throw new \ErrorException("This content encoding is not implemented.");
}

/**
* @param string $payload With padding
* @param string $userPublicKey Base 64 encoded (MIME or URL-safe)
* @param string $userAuthToken Base 64 encoded (MIME or URL-safe)
*
* @throws \ErrorException Thrown on php 8.1
* @throws \Random\RandomException Thrown on php 8.2 and higher
*/
public static function encrypt(
string $payload,
string $userPublicKey,
#[\SensitiveParameter]
string $userAuthToken,
string $contentEncoding,
ContentEncoding $contentEncoding,
): array {
return self::deterministicEncrypt(
$payload,
Expand All @@ -68,14 +66,14 @@ public static function encrypt(
}

/**
* @throws \RuntimeException
* @throws \RuntimeException|\ErrorException
*/
public static function deterministicEncrypt(
string $payload,
string $userPublicKey,
#[\SensitiveParameter]
string $userAuthToken,
string $contentEncoding,
ContentEncoding $contentEncoding,
array $localKeyObject,
string $salt
): array {
Expand Down Expand Up @@ -125,7 +123,7 @@ public static function deterministicEncrypt(
$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 @@ -145,16 +143,20 @@ public static function deterministicEncrypt(
];
}

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 "";
// @phpstan-ignore deadCode.unreachable
throw new \ValueError("This content encoding is not implemented.");
}

/**
Expand Down Expand Up @@ -195,19 +197,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 @@ -225,25 +227,26 @@ 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.');
// @phpstan-ignore deadCode.unreachable
throw new \ErrorException('This content encoding is not implemented.');
}

private static function createLocalKeyObject(): array
Expand Down Expand Up @@ -275,17 +278,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
35 changes: 26 additions & 9 deletions src/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,34 @@

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"
* @throws \ErrorException
* This is a data class. No key validation is done.
* @param string|\Minishlink\WebPush\ContentEncoding|null $contentEncoding (Optional) defaults to "aesgcm". The next mayor will use "aes128gcm" as defined to rfc8291.
*/
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.');
}
} elseif ($contentEncoding === null) {
$contentEncoding = self::defaultContentEncoding;
}
$this->contentEncoding = $contentEncoding ?: "aesgcm";
$this->contentEncoding = $contentEncoding;
}
}

Expand All @@ -45,7 +57,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 +66,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 +103,11 @@ public function getAuthToken(): ?string
* {@inheritDoc}
*/
public function getContentEncoding(): ?string
{
return $this->contentEncoding?->value;
}

public function getContentEncodingTyped(): ?ContentEncoding
{
return $this->contentEncoding;
}
Expand Down
7 changes: 4 additions & 3 deletions src/VAPID.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public static function getVapidHeaders(
string $publicKey,
#[\SensitiveParameter]
string $privateKey,
string $contentEncoding,
ContentEncoding $contentEncoding,
?int $expiration = null,
): array {
$expirationLimit = time() + 43200; // equal margin of error between 0 and 24h
Expand Down Expand Up @@ -145,19 +145,20 @@ public static function getVapidHeaders(
$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,
];
}

// @phpstan-ignore deadCode.unreachable
throw new \ErrorException('This content encoding is not supported');
}

Expand Down
Loading