diff --git a/.pubnub.yml b/.pubnub.yml index 1f2a9466..e395e14f 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,8 +1,13 @@ name: php -version: 9.0.1 +version: 9.0.2 schema: 1 scm: github.com/pubnub/php changelog: + - date: 2026-07-20 + version: 9.0.2 + changes: + - type: feature + text: "Obscure exceptions thrown by cryptors to be available in logs only." - date: 2026-06-22 version: 9.0.1 changes: @@ -550,8 +555,8 @@ sdks: - x86-64 - distribution-type: library distribution-repository: GitHub release - package-name: php-9.0.1.zip - location: https://github.com/pubnub/php/releases/tag/9.0.1 + package-name: php-9.0.2.zip + location: https://github.com/pubnub/php/releases/tag/9.0.2 requires: - name: rmccue/requests min-version: 1.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0843826d..87043f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 9.0.2 +July 20 2026 + +#### Added +- Obscure exceptions thrown by cryptors to be available in logs only. + ## 9.0.1 June 22 2026 diff --git a/README.md b/README.md index 5a284733..e5272b16 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ You will need the publish and subscribe keys to authenticate your app. Get your { "require": { - "pubnub/pubnub": "9.0.1" + "pubnub/pubnub": "9.0.2" } } ``` diff --git a/composer.json b/composer.json index 962bfd7c..dfda85dc 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "keywords": ["api", "real-time", "realtime", "real time", "ajax", "push"], "homepage": "http://www.pubnub.com/", "license": "proprietary", - "version": "9.0.1", + "version": "9.0.2", "authors": [ { "name": "PubNub", diff --git a/src/PubNub/Crypto/AesCbcCryptor.php b/src/PubNub/Crypto/AesCbcCryptor.php index ebe0766f..ae6f4029 100644 --- a/src/PubNub/Crypto/AesCbcCryptor.php +++ b/src/PubNub/Crypto/AesCbcCryptor.php @@ -4,6 +4,7 @@ use PubNub\Crypto\Payload as CryptoPayload; use PubNub\Crypto\PaddingTrait; +use PubNub\Exceptions\PubNubResponseParsingException; class AesCbcCryptor extends Cryptor { @@ -51,6 +52,14 @@ public function decrypt(CryptoPayload $payload, ?string $cipherKey = null) $secret = $this->getSecret($this->getCipherKey($cipherKey)); $iv = $payload->getCryptorData(); $decrypted = openssl_decrypt($text, static::CIPHER_ALGO, $secret, OPENSSL_RAW_DATA, $iv); + + if ($decrypted === false) { + // Use a single generic error for every crypto failure mode. The underlying + // openssl_error_string() distinguishes bad padding from wrong block length, + // which would hand a padding-oracle bit to a caller submitting crafted ciphertexts. + throw new PubNubResponseParsingException("Decryption error: message decryption failed"); + } + $result = json_decode($decrypted, false); if ($result === null) { diff --git a/src/PubNub/Crypto/LegacyCryptor.php b/src/PubNub/Crypto/LegacyCryptor.php index d09931d5..bc199a52 100644 --- a/src/PubNub/Crypto/LegacyCryptor.php +++ b/src/PubNub/Crypto/LegacyCryptor.php @@ -85,7 +85,10 @@ public function decrypt(Payload $payload, ?string $cipherKey = null) $decrypted = openssl_decrypt($data, 'aes-256-cbc', $shaCipherKey, OPENSSL_RAW_DATA, $iv); if ($decrypted === false) { - throw new PubNubResponseParsingException("Decryption error: " . openssl_error_string()); + // Use a single generic error for every crypto failure mode. The underlying + // openssl_error_string() distinguishes bad padding from wrong block length, + // which would hand a padding-oracle bit to a caller submitting crafted ciphertexts. + throw new PubNubResponseParsingException("Decryption error: message decryption failed"); } $unPadded = $this->depad($decrypted, self::BLOCK_SIZE); diff --git a/src/PubNub/PubNub.php b/src/PubNub/PubNub.php index 37da4a4e..92c726d2 100644 --- a/src/PubNub/PubNub.php +++ b/src/PubNub/PubNub.php @@ -69,7 +69,7 @@ class PubNub implements LoggerAwareInterface { - protected const SDK_VERSION = "9.0.1"; + protected const SDK_VERSION = "9.0.2"; protected const SDK_NAME = "PubNub-PHP"; public static $MAX_SEQUENCE = 65535; diff --git a/src/PubNub/PubNubCrypto.php b/src/PubNub/PubNubCrypto.php index f1117d18..4ede389b 100644 --- a/src/PubNub/PubNubCrypto.php +++ b/src/PubNub/PubNubCrypto.php @@ -2,20 +2,26 @@ namespace PubNub; - use Monolog\Logger; use PubNub\Exceptions\PubNubResponseParsingException; -class PubNubCrypto extends PubNubCryptoCore { - public function encrypt($plainText) { +class PubNubCrypto extends PubNubCryptoCore +{ + public function encrypt($plainText) + { $shaCipherKey = hash("sha256", $this->cipherKey); $paddedCipherKey = substr($shaCipherKey, 0, 32); - $encrypted = openssl_encrypt($plainText, 'aes-256-cbc', $paddedCipherKey, OPENSSL_RAW_DATA, - $this->initializationVector); + $encrypted = openssl_encrypt( + $plainText, + 'aes-256-cbc', + $paddedCipherKey, + OPENSSL_RAW_DATA, + $this->initializationVector + ); $encode = ''; - + if ($this->useRandomIV) { $encode = base64_encode($this->initializationVector . $encrypted); } else { @@ -25,7 +31,8 @@ public function encrypt($plainText) { return $encode; } - public function decrypt($cipherText, $logger = null) { + public function decrypt($cipherText, $logger = null) + { $logError = function ($message) use ($logger) { if ($logger !== null && $logger instanceof Logger) { $logger->error($message); @@ -44,12 +51,12 @@ public function decrypt($cipherText, $logger = null) { throw new PubNubResponseParsingException("Decryption error: pn_other object key missing"); } } - } else if (!is_string($cipherText)) { + } elseif (!is_string($cipherText)) { $logError("Decryption error: message is not a string: " . $cipherText); throw new PubNubResponseParsingException("Decryption error: message is not a string or object"); } - if (strlen($cipherText) === 0){ + if (strlen($cipherText) === 0) { $logError("Decryption error: message is empty"); throw new PubNubResponseParsingException("Decryption error: message is empty"); } @@ -70,12 +77,20 @@ public function decrypt($cipherText, $logger = null) { $data = $decoded; } - $decrypted = openssl_decrypt($data, 'aes-256-cbc', $paddedCipherKey, OPENSSL_RAW_DATA, - $initializationVector); + $decrypted = openssl_decrypt( + $data, + 'aes-256-cbc', + $paddedCipherKey, + OPENSSL_RAW_DATA, + $initializationVector + ); if ($decrypted === false) { + // Preserve the specific OpenSSL cause in the internal log only. The thrown message + // stays generic so a caller cannot distinguish bad padding from wrong block length + // (padding-oracle hardening). $logError("Decryption error: " . openssl_error_string()); - throw new PubNubResponseParsingException("Decryption error: " . openssl_error_string()); + throw new PubNubResponseParsingException("Decryption error: message decryption failed"); } $unPadded = $this->unPadPKCS7($decrypted, 16); diff --git a/tests/unit/CryptoModule/CryptoModuleTest.php b/tests/unit/CryptoModule/CryptoModuleTest.php index 102a8e64..f9cda378 100644 --- a/tests/unit/CryptoModule/CryptoModuleTest.php +++ b/tests/unit/CryptoModule/CryptoModuleTest.php @@ -713,6 +713,85 @@ public function testEncryptDecryptNestedStructures(): void } } + /** + * Regression test for the padding-oracle hardening. + * + * Every crypto failure mode must surface the same generic error so a caller + * submitting crafted ciphertexts cannot distinguish bad padding from a wrong + * block length (or any other OpenSSL failure) via the exception message. + */ + public function testDecryptFailureModesProduceGenericError(): void + { + $expected = 'Decryption error: message decryption failed'; + + $aesSecret = hash("sha256", $this->cipherKey, true); + $legacySecret = substr(hash("sha256", $this->cipherKey), 0, 32); + $legacyStaticIv = '0123456789012345'; + $zeroIv = str_repeat("\x00", 16); + + // A wrong (non block-aligned) length always fails deterministically. + $wrongLength = random_bytes(10); + + $aes = new AesCbcCryptor($this->cipherKey); + $legacyStatic = new LegacyCryptor($this->cipherKey, false); + $legacyRandom = new LegacyCryptor($this->cipherKey, true); + + // Block-aligned inputs guaranteed to fail PKCS#7 unpadding ("bad decrypt"). + $aesBadPadding = $this->undecryptableBlock($aesSecret, $zeroIv, 16); + $legacyStaticBadPadding = $this->undecryptableBlock($legacySecret, $legacyStaticIv, 16); + // Random-IV legacy strips the first 16 bytes as the IV before decrypting. + $legacyRandomIv = random_bytes(16); + $legacyRandomBadPadding = + $legacyRandomIv . $this->undecryptableBlock($legacySecret, $legacyRandomIv, 16); + + $cases = [ + 'aes-bad-padding' => [$aes, new CryptoPayload($aesBadPadding, $zeroIv, AesCbcCryptor::CRYPTOR_ID)], + 'aes-wrong-length' => [$aes, new CryptoPayload($wrongLength, $zeroIv, AesCbcCryptor::CRYPTOR_ID)], + 'legacy-static-bad-padding' => + [$legacyStatic, new CryptoPayload($legacyStaticBadPadding, '', LegacyCryptor::CRYPTOR_ID)], + 'legacy-static-wrong-length' => + [$legacyStatic, new CryptoPayload($wrongLength, '', LegacyCryptor::CRYPTOR_ID)], + 'legacy-random-bad-padding' => + [$legacyRandom, new CryptoPayload($legacyRandomBadPadding, '', LegacyCryptor::CRYPTOR_ID)], + 'legacy-random-wrong-length' => + [$legacyRandom, new CryptoPayload($legacyRandomIv . $wrongLength, '', LegacyCryptor::CRYPTOR_ID)], + ]; + + $messages = []; + foreach ($cases as $name => [$cryptor, $payload]) { + try { + $cryptor->decrypt($payload); + $this->fail("Expected decryption to fail for case: $name"); + } catch (PubNubResponseParsingException $e) { + $messages[$name] = $e->getMessage(); + } + } + + foreach ($messages as $name => $message) { + $this->assertSame($expected, $message, "Non-generic error for case: $name"); + $this->assertStringNotContainsString('bad decrypt', $message, "Leaked OpenSSL detail for: $name"); + $this->assertStringNotContainsString('block length', $message, "Leaked OpenSSL detail for: $name"); + $this->assertStringNotContainsString('padding', strtolower($message), "Leaked padding detail for: $name"); + } + + // Bad padding and wrong length must be indistinguishable for every cryptor. + $this->assertSame($messages['aes-bad-padding'], $messages['aes-wrong-length']); + $this->assertSame($messages['legacy-static-bad-padding'], $messages['legacy-static-wrong-length']); + $this->assertSame($messages['legacy-random-bad-padding'], $messages['legacy-random-wrong-length']); + } + + /** + * Build a block-aligned ciphertext that is guaranteed to fail PKCS#7 unpadding + * for the given secret/IV, so the "bad padding" branch is exercised deterministically. + */ + private function undecryptableBlock(string $secret, string $iv, int $bytes): string + { + do { + $data = random_bytes($bytes); + } while (openssl_decrypt($data, 'aes-256-cbc', $secret, OPENSSL_RAW_DATA, $iv) !== false); + return $data; + } + // ============================================================================ // DATA PROVIDERS // ============================================================================