Add enum for Content encoding
authorCyperghost <olaf_schmitz_1@t-online.de>
Tue, 20 Feb 2024 11:46:46 +0000 (12:46 +0100)
committerCyperghost <olaf_schmitz_1@t-online.de>
Fri, 23 Feb 2024 13:43:01 +0000 (14:43 +0100)
wcfsetup/install/files/lib/data/service/worker/ServiceWorker.class.php
wcfsetup/install/files/lib/system/background/job/ServiceWorkerDeliveryBackgroundJob.class.php
wcfsetup/install/files/lib/system/service/worker/Encoding.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/service/worker/Encryption.class.php
wcfsetup/install/files/lib/system/service/worker/VAPID.class.php

index 5d13bd9171cb11e4b9761cd035e2331c9621e1e8..48ca65e745643e5adea14f4953e420e7a367928f 100644 (file)
@@ -3,6 +3,7 @@
 namespace wcf\data\service\worker;
 
 use wcf\data\DatabaseObject;
+use wcf\system\service\worker\Encoding;
 
 /**
  * @author      Olaf Braun
@@ -19,9 +20,6 @@ use wcf\data\DatabaseObject;
  */
 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.
      */
@@ -29,4 +27,12 @@ class ServiceWorker extends DatabaseObject
     {
         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);
+    }
 }
index 282c1fd1b6c92878647ff522123f4cc46740bb9a..cbf5cfdedbed2e53437264d3aa75449ce0d85b34 100644 (file)
@@ -91,7 +91,6 @@ final class ServiceWorkerDeliveryBackgroundJob extends AbstractBackgroundJob
 
             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);
diff --git a/wcfsetup/install/files/lib/system/service/worker/Encoding.class.php b/wcfsetup/install/files/lib/system/service/worker/Encoding.class.php
new file mode 100644 (file)
index 0000000..1b1c540
--- /dev/null
@@ -0,0 +1,129 @@
+<?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',
+        };
+    }
+}
index 3e5badfc51ad72ac1c4583a8df8f9968666fa8b4..add8e1fca2d12d3f828b3be612947cff0066c325 100644 (file)
@@ -50,27 +50,28 @@ final class Encryption
         $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(
@@ -82,15 +83,14 @@ final class Encryption
             $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
index 35674d07f0769e6a848b3d4674d967668e60bce9..8053f913994a1704055764b1af7d4459c81a4043 100644 (file)
@@ -64,10 +64,10 @@ final class VAPID
             ->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 . '"');