namespace wcf\data\service\worker;
use wcf\data\DatabaseObject;
+use wcf\system\service\worker\Encoding;
/**
* @author Olaf Braun
*/
class ServiceWorker extends DatabaseObject
{
- public const CONTENT_ENCODING_AESGCM = 'aesgcm';
- public const CONTENT_ENCODING_AES128GCM = 'aes128gcm';
-
/**
* Parses the endpoint and returns the scheme and host.
*/
{
return \parse_url($this->endpoint, PHP_URL_SCHEME) . '://' . \parse_url($this->endpoint, PHP_URL_HOST);
}
+
+ /**
+ * Returns the content encoding.
+ */
+ public function getContentEncoding(): Encoding
+ {
+ return Encoding::fromString($this->contentEncoding);
+ }
}
ServiceWorkerHandler::getInstance()->sendToServiceWorker($serviceWorker, JSON::encode($content));
} catch (ClientExceptionInterface $e) {
- \wcfDebug($e->getCode(), $e);
if ($e->getCode() === 413) {
// Payload too large, we can't do anything here other than discard the message.
\wcf\functions\exception\logThrowable($e);
--- /dev/null
+<?php
+
+namespace wcf\system\service\worker;
+
+use ParagonIE\ConstantTime\Base64;
+
+/**
+ * @author Olaf Braun
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ */
+enum Encoding
+{
+ case Aes128Gcm;
+ case AesGcm;
+
+ public static function fromString(string $encoding): self
+ {
+ return match ($encoding) {
+ 'aes128gcm' => self::Aes128Gcm,
+ 'aesgcm' => self::AesGcm,
+ default => throw new \InvalidArgumentException("Invalid encoding: {$encoding}"),
+ };
+ }
+
+ public function getEncryptionContentCodingHeader(
+ int $length,
+ string $salt,
+ string $publicKey
+ ): string {
+ return match ($this) {
+ /** {@link https://datatracker.ietf.org/doc/html/rfc8188#section-2.1} */
+ self::Aes128Gcm => \pack(
+ 'A*NCA*',
+ $salt,
+ $length,
+ \strlen($publicKey),
+ $publicKey
+ ),
+ self::AesGcm => '',
+ };
+ }
+
+ public function pad(#[\SensitiveParameter] string $payload): string
+ {
+ $length = \mb_strlen($payload, '8bit');
+ $paddingLength = ServiceWorkerHandler::MAX_PAYLOAD_LENGTH - $length;
+ $padding = \str_repeat("\x00", $paddingLength);
+
+ return match ($this) {
+ self::Aes128Gcm => "{$payload}\x02{$padding}",
+ self::AesGcm => \sprintf(
+ '%s%s%s',
+ \pack('n', $paddingLength),
+ $padding,
+ $payload,
+ ),
+ };
+ }
+
+ public function getInfo(string $type, ?string $context): string
+ {
+ if ($this === self::AesGcm) {
+ \assert($context !== null);
+ \assert(\mb_strlen($context, '8bit') === 135);
+
+ return \sprintf(
+ 'Content-Encoding: %s%s%s',
+ $type,
+ "\x00",
+ Encryption::CURVE_ALGORITHM . $context
+ );
+ }
+ return "Content-Encoding: {$type}\x00";
+ }
+
+ /**
+ * {@link https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-encryption-encoding-00#section-4.2}
+ */
+ public function getContext(string $clientPublicKey, string $serverPublicKey): ?string
+ {
+ if ($this === self::Aes128Gcm) {
+ return null;
+ }
+ \assert(\mb_strlen($clientPublicKey, '8bit') === VAPID::PUBLIC_KEY_LENGTH);
+
+ $len = \pack('n', 65);
+
+ return \sprintf(
+ "\x00%s%s%s%s",
+ $len,
+ $clientPublicKey,
+ $len,
+ $serverPublicKey
+ );
+ }
+
+ public function getIKM(
+ string $authToken,
+ #[\SensitiveParameter] string $sharedSecret,
+ string $userPublicKey,
+ string $newPublicKey
+ ): string {
+ if ($this === Encoding::AesGcm) {
+ $info = "Content-Encoding: auth\x00";
+ } elseif ($this === Encoding::Aes128Gcm) {
+ $info = "WebPush: info\x00{$userPublicKey}{$newPublicKey}";
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+
+ return \hash_hkdf(
+ Encryption::HASH_ALGORITHM,
+ $sharedSecret,
+ 32,
+ $info,
+ Base64::decode($authToken, true)
+ );
+ }
+
+ public function toString(): string
+ {
+ return match ($this) {
+ self::Aes128Gcm => 'aes128gcm',
+ self::AesGcm => 'aesgcm',
+ };
+ }
+}
$sharedSecret = Encryption::getSharedSecret($userJwk, $newJwk);
// Section 3.3
- $ikm = Encryption::getIKM($serviceWorker, $sharedSecret, $userPublicKey, $newPublicKey);
+ $encoding = $serviceWorker->getContentEncoding();
+ $ikm = $encoding->getIKM($serviceWorker->authToken, $sharedSecret, $userPublicKey, $newPublicKey);
// Section 3.4
$salt = \random_bytes(16);
- $content = Encryption::createContext($userPublicKey, $newPublicKey, $serviceWorker->contentEncoding);
+ $content = $encoding->getContext($userPublicKey, $newPublicKey);
$cek = \hash_hkdf(
Encryption::HASH_ALGORITHM,
$ikm,
16,
- Encryption::createInfo($serviceWorker->contentEncoding, $content, $serviceWorker->contentEncoding),
+ $encoding->getInfo($encoding->toString(), $content),
$salt
);
$nonce = \hash_hkdf(
Encryption::HASH_ALGORITHM,
$ikm,
12,
- Encryption::createInfo('nonce', $content, $serviceWorker->contentEncoding),
+ $encoding->getInfo('nonce', $content),
$salt
);
// Section 4
- $payload = Encryption::addPadding($payload, $serviceWorker->contentEncoding);
+ $payload = $encoding->pad($payload);
$tag = '';
$encryptedText = \openssl_encrypt(
$tag
);
- if ($serviceWorker->contentEncoding === ServiceWorker::CONTENT_ENCODING_AESGCM) {
+ if ($serviceWorker->getContentEncoding() === Encoding::AesGcm) {
$headers['Encryption'] = 'salt=' . Base64Url::encode($salt);
$headers['Crypto-Key'] = 'dh=' . Base64Url::encode($newPublicKey);
}
$record = $encryptedText . $tag;
$body = new Stream('php://temp', 'wb+');
- $body->write(Encryption::getEncryptionContentCodingHeader(
- $serviceWorker->contentEncoding,
+ $body->write($encoding->getEncryptionContentCodingHeader(
\mb_strlen($record, '8bit'),
$salt,
$newPublicKey
->build();
$jwt = $compactSerializer->serialize($jws, 0);
- if ($serviceWorker->contentEncoding === ServiceWorker::CONTENT_ENCODING_AESGCM) {
+ if ($serviceWorker->getContentEncoding() === Encoding::AesGcm) {
$request = $request->withHeader('authorization', "WebPush {$jwt}");
return Util::updateCryptoKeyHeader($request, 'p256ecdsa', SERVICE_WORKER_PUBLIC_KEY);
- } elseif ($serviceWorker->contentEncoding === ServiceWorker::CONTENT_ENCODING_AES128GCM) {
+ } elseif ($serviceWorker->getContentEncoding() === Encoding::Aes128Gcm) {
return $request->withHeader('authorization', \sprintf("vapid t=%s, k=%s", $jwt, SERVICE_WORKER_PUBLIC_KEY));
} else {
throw new \InvalidArgumentException('Invalid content encoding: "' . $serviceWorker->contentEncoding . '"');