diff --git a/examples/Time.php b/examples/Time.php index d544bddc..954dfc4d 100755 --- a/examples/Time.php +++ b/examples/Time.php @@ -3,6 +3,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; use PubNub\Models\Consumer\PNTimeResult; +use PubNub\Crypto\Cryptor; $pnconfig = \PubNub\PNConfiguration::demoKeys(); $pubnub = new \PubNub\PubNub($pnconfig); @@ -10,3 +11,4 @@ $result = $pubnub->time()->sync(); printf("Server Time is: %s", date("Y-m-d H:i:s", $result->getTimetoken())); + diff --git a/src/PubNub/Crypto/AesCbcCryptor.php b/src/PubNub/Crypto/AesCbcCryptor.php new file mode 100644 index 00000000..0915ae71 --- /dev/null +++ b/src/PubNub/Crypto/AesCbcCryptor.php @@ -0,0 +1,62 @@ +cipherKey = $cipherKey; + } + + public function getIV(): string + { + return random_bytes(self::IV_LENGTH); + } + + public function getCipherKey(): string + { + return $this->cipherKey; + } + + protected function getSecret($cipherKey): string + { + $key = !is_null($cipherKey) ? $cipherKey : $this->cipherKey; + return hash("sha256", $key, true); + } + + public function encrypt(string $text, ?string $cipherKey = null): CryptoPayload + { + $secret = $this->getSecret($cipherKey); + $iv = $this->getIV(); + $encrypted = openssl_encrypt($text, self::CIPHER_ALGO, $secret, OPENSSL_RAW_DATA, $iv); + return new CryptoPayload($encrypted, $iv, self::CRYPTOR_ID); + } + + public function decrypt(CryptoPayload $payload, ?string $cipherKey = null): string + { + $text = $payload->getData(); + $secret = $this->getSecret($cipherKey); + $iv = $payload->getCryptorData(); + $decrypted = openssl_decrypt($text, self::CIPHER_ALGO, $secret, OPENSSL_RAW_DATA, $iv); + $result = json_decode($decrypted); + + if ($result === null) { + return $decrypted; + } else { + return $result; + } + } +} diff --git a/src/PubNub/Crypto/Cryptor.php b/src/PubNub/Crypto/Cryptor.php new file mode 100644 index 00000000..0d6d9767 --- /dev/null +++ b/src/PubNub/Crypto/Cryptor.php @@ -0,0 +1,13 @@ +sentinel = $sentinel; + $this->cryptorId = $cryptorId; + $this->cryptorData = $cryptorData; + $this->length = $length; + } + + public function getSentinel(): string + { + return $this->sentinel; + } + + public function getCryptorId(): string + { + return $this->cryptorId; + } + + public function getCryptorData(): string + { + return $this->cryptorData; + } + + public function getLength(): int + { + return $this->length; + } +} diff --git a/src/PubNub/Crypto/LegacyCryptor.php b/src/PubNub/Crypto/LegacyCryptor.php new file mode 100644 index 00000000..6565fe4d --- /dev/null +++ b/src/PubNub/Crypto/LegacyCryptor.php @@ -0,0 +1,101 @@ +cipherKey = $key; + $this->useRandomIV = $useRandomIV; + } + + public function getIV(): string + { + if (!$this->useRandomIV) { + return self::STATIC_IV; + } + return random_bytes(static::IV_LENGTH); + } + + public function getCipherKey(): string + { + return $this->cipherKey; + } + + public function encrypt(string $text, ?string $cipherKey = null): Payload + { + $iv = $this->getIV(); + $shaCipherKey = substr(hash("sha256", $this->cipherKey), 0, 32); + $padded = $this->pad($text); + $encrypted = openssl_encrypt($text, self::CIPHER_ALGO, $shaCipherKey, OPENSSL_RAW_DATA, $iv); + if ($this->useRandomIV) { + $encryptedWithIV = $iv . $encrypted; + } else { + $encryptedWithIV = $encrypted; + } + return new Payload($encryptedWithIV, '', self::CRYPTOR_ID); + } + + public function decrypt(Payload $payload, ?string $cipherKey = null): string + { + $text = $payload->getData(); + if (strlen($text) === 0) { + throw new PubNubResponseParsingException("Decryption error: message is empty"); + } + + if (is_array($text)) { + if (array_key_exists("pn_other", $text)) { + $text = $text["pn_other"]; + } else { + if (is_array($text)) { + throw new PubNubResponseParsingException("Decryption error: message is not a string"); + } else { + throw new PubNubResponseParsingException("Decryption error: pn_other object key missing"); + } + } + } elseif (!is_string($text)) { + throw new PubNubResponseParsingException("Decryption error: message is not a string or object"); + } + + $shaCipherKey = substr(hash("sha256", $this->cipherKey), 0, 32); + + if ($this->useRandomIV) { + $iv = substr($text, 0, 16); + $data = substr($text, 16); + } else { + $iv = self::STATIC_IV; + $data = $text; + } + $decrypted = openssl_decrypt($data, 'aes-256-cbc', $shaCipherKey, OPENSSL_RAW_DATA, $iv); + + if ($decrypted === false) { + throw new PubNubResponseParsingException("Decryption error: " . openssl_error_string()); + } + + $unPadded = $this->depad($decrypted); + + $result = json_decode($unPadded); + + if ($result === null) { + return $unPadded; + } else { + return $result; + } + } +} diff --git a/src/PubNub/Crypto/PaddingTrait.php b/src/PubNub/Crypto/PaddingTrait.php new file mode 100644 index 00000000..9eefd8b8 --- /dev/null +++ b/src/PubNub/Crypto/PaddingTrait.php @@ -0,0 +1,48 @@ + 0; $i--) { + if (ord($data [$i] != $padLength)) { + break; + } + } + return substr($data, 0, $i + 1); + } + return $data; + } +} diff --git a/src/PubNub/Crypto/Payload.php b/src/PubNub/Crypto/Payload.php new file mode 100644 index 00000000..4b136c37 --- /dev/null +++ b/src/PubNub/Crypto/Payload.php @@ -0,0 +1,32 @@ +data = $data; + $this->cryptorData = $cryptorData; + $this->cryptorId = $cryptorId; + } + + public function getData(): string + { + return $this->data; + } + + public function getCryptorData(): ?string + { + return $this->cryptorData; + } + + public function getCryptorId(): ?string + { + return $this->cryptorId; + } +} diff --git a/src/PubNub/CryptoModule.php b/src/PubNub/CryptoModule.php new file mode 100644 index 00000000..98c94a4e --- /dev/null +++ b/src/PubNub/CryptoModule.php @@ -0,0 +1,160 @@ +cryptorMap = $cryptorMap; + $this->defaultCryptorId = $defaultCryptorId; + } + + public function registerCryptor(Cryptor $cryptor, ?string $cryptorId = null): self + { + if (is_null($cryptorId)) { + $cryptorId = $cryptor::CRYPTOR_ID; + } + + if (strlen($cryptorId) != 4) { + throw new PubNubCryptoException('Malformed cryptor id'); + } + + if (key_exists($cryptorId, $this->cryptorMap)) { + throw new PubNubCryptoException('Cryptor id already in use'); + } + + if (!$cryptor instanceof Cryptor) { + throw new PubNubCryptoException('Invalid Cryptor instance'); + } + + $this->cryptorMap[$cryptorId] = $cryptor; + + return $this; + } + + protected function stringify(mixed $data): string + { + if (is_string($data)) { + return $data; + } else { + return json_encode($data); + } + } + + public function encrypt(mixed $data, ?string $cryptorId = null): string + { + if (($data) == '') { + throw new PubNubResponseParsingException("Encryption error: message is empty"); + } + $cryptorId = is_null($cryptorId) ? $this->defaultCryptorId : $cryptorId; + $cryptor = $this->cryptorMap[$cryptorId]; + $text = $this->stringify($data); + $cryptoPayload = $cryptor->encrypt($text); + $header = $this->encodeHeader($cryptoPayload); + return base64_encode($header . $cryptoPayload->getData()); + } + + public function decrypt(string $input) + { + if (strlen($input) == '') { + throw new PubNubResponseParsingException("Decryption error: message is empty"); + } + $data = base64_decode($input); + $header = $this->decodeHeader($data); + + if (!$this->cryptorMap[$header->getCryptorId()]) { + throw new PubNubCryptoException('unknown cryptor error'); + } + $payload = new CryptoPayload( + substr($data, $header->getLength()), + $header->getCryptorData(), + $header->getCryptorId(), + ); + + return $this->cryptorMap[$header->getCryptorId()]->decrypt($payload); + } + + public function encodeHeader(CryptoPayload $payload): string + { + if ($payload->getCryptorId() == self::FALLBACK_CRYPTOR_ID) { + return ''; + } + + $version = chr(CryptoHeader::HEADER_VERSION); + + $crdLen = strlen($payload->getCryptorData()); + if ($crdLen > 65535) { + throw new PubNubCryptoException('Cryptor data is too long'); + } + + if ($crdLen < 255) { + $cryptorDataLength = chr($crdLen); + } else { + $hexlen = str_split(str_pad(dechex($crdLen), 4, 0, STR_PAD_LEFT), 2); + $cryptorDataLength = chr(255) . chr(hexdec($hexlen[0])) . chr(hexdec($hexlen[1])); + } + + return self::SENTINEL . $version . $payload->getCryptorId() . $cryptorDataLength . $payload->getCryptorData(); + } + + public function decodeHeader(string $header): CryptoHeader + { + if (strlen($header < 10) or substr($header, 0, 4) != self::SENTINEL) { + return new CryptoHeader('', self::FALLBACK_CRYPTOR_ID, '', 0); + } + $sentinel = substr($header, 0, 4); + $version = ord($header[4]); + if ($version > CryptoHeader::HEADER_VERSION) { + throw new PubNubCryptoException('unknown cryptor error'); + } + $cryptorId = substr($header, 5, 4); + $cryptorDataLength = ord($header[9]); + if ($cryptorDataLength < 255) { + $cryptorData = substr($header, 10, $cryptorDataLength); + $headerLength = 10 + $cryptorDataLength; + } else { + $cryptorDataLength = ord($header[10]) * 256 + ord($header[11]); + $cryptorData = substr($header, 12, $cryptorDataLength); + $headerLength = 12 + $cryptorDataLength; + } + return new CryptoHeader($sentinel, $cryptorId, $cryptorData, $headerLength); + } + + public static function legacyCryptor(string $cipherKey, bool $useRandomIV): self + { + return new self( + [ + LegacyCryptor::CRYPTOR_ID => new LegacyCryptor($cipherKey, $useRandomIV), + AesCbcCryptor::CRYPTOR_ID => new AesCbcCryptor($cipherKey), + ], + LegacyCryptor::CRYPTOR_ID + ); + } + + public static function aesCbcCryptor(string $cipherKey, bool $useRandomIV): self + { + return new self( + [ + LegacyCryptor::CRYPTOR_ID => new LegacyCryptor($cipherKey, $useRandomIV), + AesCbcCryptor::CRYPTOR_ID => new AesCbcCryptor($cipherKey), + ], + aesCbcCryptor::CRYPTOR_ID + ); + } +} diff --git a/src/PubNub/Exceptions/PubNubCryptoException.php b/src/PubNub/Exceptions/PubNubCryptoException.php new file mode 100644 index 00000000..72f54447 --- /dev/null +++ b/src/PubNub/Exceptions/PubNubCryptoException.php @@ -0,0 +1,29 @@ +originalException; + } + + /** + * @param \Exception $originalException + * @return $this + */ + public function setOriginalException($originalException) + { + $this->originalException = $originalException; + $this->message = $originalException->getMessage(); + + return $this; + } +} diff --git a/tests/unit/CryptoModule/CryptoModuleTest.php b/tests/unit/CryptoModule/CryptoModuleTest.php new file mode 100644 index 00000000..dc0a1943 --- /dev/null +++ b/tests/unit/CryptoModule/CryptoModuleTest.php @@ -0,0 +1,132 @@ +decrypt($encrypted); + } catch (PubNubResponseParsingException $e) { + $decrypted = $e->getMessage(); + } + $this->assertEquals($expected, $decrypted); + } + + /** + * @dataProvider encodeProvider + * @param string $message + * @param mixed $expected + * @return void + */ + public function testEnode(CryptoModule $module, string $message, mixed $expected): void + { + try { + $encrypted = $module->encrypt($message); + if (!$expected) { + $this->assertEquals($message, $module->decrypt($encrypted)); + return; + } + } catch (PubNubResponseParsingException $e) { + $encrypted = $e->getMessage(); + } + $this->assertEquals($expected, $encrypted); + } + + protected function encodeProvider(): Generator + { + $legacyRandomModule = CryptoModule::legacyCryptor($this->cipherKey, true); + $legacyStaticModule = CryptoModule::legacyCryptor($this->cipherKey, false); + $aesCbcModuleStatic = CryptoModule::aesCbcCryptor($this->cipherKey, false); + $aesCbcModuleRandom = CryptoModule::aesCbcCryptor($this->cipherKey, true); + + yield [$legacyRandomModule, '', 'Encryption error: message is empty']; + yield [$legacyStaticModule, '', 'Encryption error: message is empty']; + yield [$aesCbcModuleStatic, '', 'Encryption error: message is empty']; + yield [ + $legacyStaticModule, + "Hello world encrypted with legacyModuleStaticIv", + "OtYBNABjeAZ9X4A91FQLFBo4th8et/pIAsiafUSw2+L8iWqJlte8x/eCL5cyjzQa", + ]; + yield [ + $legacyRandomModule, + "Hello world encrypted with legacyModuleRandomIv", + null, + ]; + yield [ + $legacyStaticModule, + "Hello world encrypted with legacyModuleStaticIv", + null, + ]; + // test fallback decrypt with static IV + yield [ + $aesCbcModuleStatic, + "Hello world encrypted with legacyModuleStaticIv", + null, + ]; + // test falback decrypt with random IV + yield [ + $aesCbcModuleRandom, + "Hello world encrypted with legacyModuleRandomIv", + null, + ]; + yield [ + $aesCbcModuleRandom, + 'Hello world encrypted with aesCbcModule', + null, + ]; + } + + protected function decodeProvider(): Generator + { + $legacyRandomModule = CryptoModule::legacyCryptor($this->cipherKey, true); + $legacyStaticModule = CryptoModule::legacyCryptor($this->cipherKey, false); + $aesCbcModuleStatic = CryptoModule::aesCbcCryptor($this->cipherKey, false); + $aesCbcModuleRandom = CryptoModule::aesCbcCryptor($this->cipherKey, true); + + yield [$legacyRandomModule, '', 'Decryption error: message is empty']; + yield [$legacyStaticModule, '', 'Decryption error: message is empty']; + yield [$aesCbcModuleStatic, '', 'Decryption error: message is empty']; + yield [ + $legacyRandomModule, + "T3J9iXI87PG9YY/lhuwmGRZsJgA5y8sFLtUpdFmNgrU1IAitgAkVok6YP7lacBiVhBJSJw39lXCHOLxl2d98Bg==", + "Hello world encrypted with legacyModuleRandomIv", + ]; + yield [ + $legacyStaticModule, + "OtYBNABjeAZ9X4A91FQLFBo4th8et/pIAsiafUSw2+L8iWqJlte8x/eCL5cyjzQa", + "Hello world encrypted with legacyModuleStaticIv", + ]; + // test fallback decrypt with static IV + yield [ + $aesCbcModuleStatic, + "OtYBNABjeAZ9X4A91FQLFBo4th8et/pIAsiafUSw2+L8iWqJlte8x/eCL5cyjzQa", + "Hello world encrypted with legacyModuleStaticIv", + ]; + // test falback decrypt with random IV + yield [ + $aesCbcModuleRandom, + "T3J9iXI87PG9YY/lhuwmGRZsJgA5y8sFLtUpdFmNgrU1IAitgAkVok6YP7lacBiVhBJSJw39lXCHOLxl2d98Bg==", + "Hello world encrypted with legacyModuleRandomIv", + ]; + yield [ + $aesCbcModuleRandom, + 'UE5FRAFBQ1JIEKzlyoyC/jB1hrjCPY7zm+X2f7skPd0LBocV74cRYdrkRQ2BPKeA22gX/98pMqvcZtFB6TCGp3Zf1M8F730nlfk=', + 'Hello world encrypted with aesCbcModule', + ]; + } +} diff --git a/tests/unit/CryptoModule/HeaderEncoderTest.php b/tests/unit/CryptoModule/HeaderEncoderTest.php new file mode 100644 index 00000000..ac359540 --- /dev/null +++ b/tests/unit/CryptoModule/HeaderEncoderTest.php @@ -0,0 +1,116 @@ +module = new CryptoModule([], "0000"); + } + + /** + * @dataProvider provideDecodeHeader + * @param string $header + * @param CryptoHeader $expected + * @return void + */ + public function testDecodeHeader(string $header, CryptoHeader $expected): void + { + $decoded = $this->module->decodeHeader($header); + $this->assertEquals($expected, $decoded); + } + + /** + * @dataProvider provideEncodeHeader + * + * @param CryptoHeader $expected + * @param string $ + * @return void + */ + public function testEncodeHeader(CryptoPayload $payload, string $expected): void + { + $encoded = $this->module->encodeHeader($payload); + $this->assertEquals($expected, $encoded); + } + + public function provideDecodeHeader(): Generator + { + // decoding empty string should point to fallback cryptor + yield ["", new CryptoHeader("", CryptoModule::FALLBACK_CRYPTOR_ID, "", 0)]; + + // decoding header without cryptor data + yield ["PNED\x01ACRH\x00", new CryptoHeader("PNED", "ACRH", "", 10)]; + + // decoding with any data should add data length segment + $cryptorData = "\x20"; + yield [ + "PNED\x01ACRH\x01" . $cryptorData, + new CryptoHeader("PNED", "ACRH", $cryptorData, 10 + strlen($cryptorData)) + ]; + + // if cryptor data is less than 255 characters data length segment is 1 byte long + $cryptorData = str_repeat("\x20", 254); + yield [ + "PNED\x01ACRH\xfe" . $cryptorData, + new CryptoHeader("PNED", "ACRH", $cryptorData, 10 + strlen($cryptorData)) + ]; + + // if cryptor data is greater than or equal 255 characters data length segment is 3 bytes long + $cryptorData = str_repeat("\x20", 255); + yield [ + "PNED\x01ACRH\xff\x00\xff" . $cryptorData, + new CryptoHeader("PNED", "ACRH", $cryptorData, 12 + strlen($cryptorData)) + ]; + + $cryptorData = str_repeat("\x20", 65535); + yield [ + "PNED\x01ACRH\xff\xff\xff" . $cryptorData, + new CryptoHeader("PNED", "ACRH", $cryptorData, 12 + strlen($cryptorData)) + ]; + } + + public function provideEncodeHeader(): Generator + { + $message = ""; + $cryptorData = ""; + // encode empty header for fallback cryptor + yield [new CryptoPayload($message, $cryptorData, CryptoModule::FALLBACK_CRYPTOR_ID), ""]; + + // encode header without cryptor data + yield [new CryptoPayload($message, $cryptorData, AesCbcCryptor::CRYPTOR_ID), "PNED\x01ACRH\x00"]; + + // header with cryptor data should include length byte + $cryptorData = "\x20"; + yield [ + new CryptoPayload($message, $cryptorData, AesCbcCryptor::CRYPTOR_ID), + "PNED\x01ACRH\x01" . $cryptorData, + ]; + $cryptorData = str_repeat("\x20", 254); + yield [ + new CryptoPayload($message, $cryptorData, AesCbcCryptor::CRYPTOR_ID), + "PNED\x01ACRH\xfe" . $cryptorData, + ]; + + // encoding header with cryptor data longer than 254 bytes should include three length bytes + $cryptorData = str_repeat("\x20", 255); + yield [ + new CryptoPayload($message, $cryptorData, AesCbcCryptor::CRYPTOR_ID), + "PNED\x01ACRH\xff\x00\xff" . $cryptorData, + ]; + $cryptorData = str_repeat("\x20", 65535); + yield [ + new CryptoPayload($message, $cryptorData, AesCbcCryptor::CRYPTOR_ID), + "PNED\x01ACRH\xff\xff\xff" . $cryptorData, + ]; + } +} diff --git a/tests/unit/CryptoModule/PaddingTest.php b/tests/unit/CryptoModule/PaddingTest.php new file mode 100644 index 00000000..756cabd1 --- /dev/null +++ b/tests/unit/CryptoModule/PaddingTest.php @@ -0,0 +1,59 @@ +cryptor = new LegacyCryptor("myCipherKey", false); + } + + /** + * @dataProvider padProvider + * @param string $plain + * @param string $padded + * @return void + * @throws InvalidArgumentException + * @throws ExpectationFailedException + */ + public function testPad(string $plain, string $padded): void + { + $this->assertEquals($this->cryptor->pad($plain), $padded); + } + + /** + * @dataProvider depadProvider + * @param string $padded + * @param string $expected + * @return void + * @throws InvalidArgumentException + * @throws ExpectationFailedException + */ + public function testDepad(string $padded, string $expected): void + { + $this->assertEquals($this->cryptor->depad($padded), $expected); + } + + public function padProvider(): Generator + { + yield ["123456789012345", "123456789012345\x01"]; + yield ["12345678901234", "12345678901234\x02\x02"]; + yield ["1234567890123456", "1234567890123456" . str_repeat("\x10", 16)]; + } + + public function depadProvider(): Generator + { + yield ["123456789012345\x01", "123456789012345"]; + yield ["12345678901234\x02\x02", "12345678901234"]; + yield ["1234567890123456" . str_repeat("\x10", 16), "1234567890123456"]; + yield ["1234567890123456", "1234567890123456"]; + } +}