diff --git a/.gitignore b/.gitignore index dc5ccc3..f0fda76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ composer.phar composer.lock vendor/ +build/ +.phpunit.result.cache diff --git a/.travis.yml b/.travis.yml index 4e602a8..5341408 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,7 @@ language: php php: - - 5.5 - - 5.6 - - 7.0 + - 7.1 + - 7.4 addons: code_climate: repo_token: d2e633c11ce9b2f6cdd21d775d295c9836064eaebe96590c2cb3b2370f893555 @@ -14,4 +13,4 @@ script: - ./vendor/bin/phpcs --ignore=*/vendor/* --standard=PSR2 . - ./vendor/bin/phpcs --standard=./vendor/athens/standard/ruleset.xml src after_script: - - vendor/bin/test-reporter \ No newline at end of file + - vendor/bin/test-reporter diff --git a/README.md b/README.md index e59cf10..0395de1 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,13 @@ For example: - + + + + @@ -76,7 +79,7 @@ Use This client library provides a `Cipher` class and one Propel2 Behavior class. -To designate a field as encrypted in your Propel schema, set its type as `varbinary` and include the `encryption` behavior. You may include multiple columns in the `encryption` behavior: +To designate a field as encrypted in your Propel schema, set its type as `VARBINARY`, `LONGVARBINARY` or `BLOB` and include the `encryption` behavior. You may include multiple columns in the `encryption` behavior: ``` @@ -109,11 +112,40 @@ That's it! The class setters for `MySecretData` and `MySecretData2` now seamless Remember that search/find and sort are now *broken* for `MySecretData` and `MySecretData2`, for reasons discussed above. +## Filtering +By default all encrypted columns are not searchable. It's possible to make all encrypted columns of a table searchable by setting a parameter `searchable` to `true` +``` +
+ + + + + + + + +
+``` +It's also possible to make a particular column as searchable using `column_name_searchable_*` prefix +``` + + + + + + + + + + +
+``` +> **Be aware:** For the searchable columns will be used a fixed IV. It looses data security. Compatibility ============= -* PHP 5.5, 5.6, 7.0 +* PHP >=7.1 * Propel2 Todo diff --git a/composer.json b/composer.json index c5a27c9..d3fbe4a 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,11 @@ ] }, "require": { + "php": ">=7.1", "propel/propel": "~2.0@dev" }, "require-dev": { - "phpdocumentor/phpdocumentor": "2.7.*", - "phpunit/phpunit": "4.5.*", + "phpunit/phpunit": ">=7.0", "codeclimate/php-test-reporter": "dev-master", "athens/standard": "*" }, diff --git a/src/Cipher.php b/src/Cipher.php index 12f9985..116643d 100644 --- a/src/Cipher.php +++ b/src/Cipher.php @@ -11,14 +11,18 @@ */ class Cipher { - const IV_SIZE = 16; + const ENCRYPTION_METHOD = "aes-256-cbc"; - /** @var Cipher */ + /** + * @var Cipher + */ protected static $instance; - /** @var string */ + /** + * @var string + */ protected $passphrase; /** @@ -32,12 +36,18 @@ protected function __construct($passphrase) /** * Converts a plain-text string into an encrypted string * - * @param string $string Plain-text to encrypt. - * @return string The encrypted string. + * @param string|null $string Plain-text to encrypt. + * + * @return string|null The encrypted string. */ - public function encrypt($string) + public function encrypt(?string $string): ?string { - $iv = mcrypt_create_iv(self::IV_SIZE, MCRYPT_RAND); + if ($string === null) { + return $string; + } + + $iv = random_bytes(self::IV_SIZE); + return $this->doEncrypt($string, $iv); } @@ -58,21 +68,33 @@ public function encrypt($string) * This method is employed for encrypting Propel columns that are designated as 'searchable' * in the included EncryptionBehavior. * - * @param string $string Plain-text to encrypt. - * @return string The encrypted string. + * @param string|null $string Plain-text to encrypt. + * + * @return string|null The encrypted string. */ - public function deterministicEncrypt($string) + public function deterministicEncrypt(?string $string): ?string { + if ($string === null) { + return $string; + } + $iv = str_repeat("0", self::IV_SIZE); + + // prevent second encryption during ModelCriteria::findOneOrCreate() + if (strpos($string, $iv) === 0) { + return $string; + } + return $this->doEncrypt($string, $iv); } /** * @param string $string * @param string $iv + * * @return string */ - protected function doEncrypt($string, $iv) + protected function doEncrypt(string $string, string $iv): string { return $iv.openssl_encrypt($string, self::ENCRYPTION_METHOD, $this->passphrase, 0, $iv); } @@ -81,11 +103,13 @@ protected function doEncrypt($string, $iv) * Converts an encrypted string into a plain-text string * * @param string $encryptedMessage The encrypted string. + * * @return string The plaint-text string. */ - public function decrypt($encryptedMessage) + public function decrypt(string $encryptedMessage): string { $iv = substr($encryptedMessage, 0, self::IV_SIZE); + return openssl_decrypt( substr($encryptedMessage, self::IV_SIZE), self::ENCRYPTION_METHOD, @@ -98,9 +122,10 @@ public function decrypt($encryptedMessage) /** * @param resource $encryptedStream + * * @return null|string */ - public function decryptStream($encryptedStream) + public function decryptStream($encryptedStream): ?string { if ($encryptedStream === null) { return null; @@ -111,11 +136,13 @@ public function decryptStream($encryptedStream) /** * @param string $passphrase The passphrase to be used to encrypt/decrypt data. + * * @return void + * * @throws \Exception If you attempt to initialize the cipher more than one time * in a page-load via ::createInstance. */ - public static function createInstance($passphrase) + public static function createInstance(string $passphrase): void { if (self::$instance !== null) { throw new \Exception( @@ -128,9 +155,10 @@ public static function createInstance($passphrase) /** * @return Cipher + * * @throws \Exception if ::getInstance is called before cipher is initialized via ::createInstance. */ - public static function getInstance() + public static function getInstance(): self { if (self::$instance === null) { throw new \Exception( @@ -138,6 +166,7 @@ public static function getInstance() 'Call Cipher::createInstance($passphrase) before ::getInstance().' ); } + return self::$instance; } } diff --git a/src/EncryptionBehavior.php b/src/EncryptionBehavior.php index e9f7ad6..1d64395 100644 --- a/src/EncryptionBehavior.php +++ b/src/EncryptionBehavior.php @@ -11,10 +11,11 @@ */ class EncryptionBehavior extends Behavior { - - /** @var array */ + /** + * @var array + */ protected $parameters = [ - 'searchable' => false + 'searchable' => false, ]; /** @@ -29,36 +30,37 @@ public function allowMultiple() /** * @param string $script + * * @return void + * * @throws \Exception If the schema specifies encryption on fields which are not * VARBINARY. */ public function tableMapFilter(&$script) { + if (static::encryptedColumnsDeclarationExists($script) === false) { + $insertLocation = strpos($script, ";", strpos($script, "const TABLE_NAME")) + 1; + static::insertEncryptedColumnsDeclaration($script, $insertLocation); + static::insertEncryptedColumnNameAccessMethods($script); + } + $table = $this->getTable(); + $columnNames = $this->getColumnNames(); + $searchableColumnNames = $this->getSearchableColumnNames(); - foreach ($this->getColumnNames() as $columnName) { + foreach ($columnNames as $columnName) { $column = $table->getColumn($columnName); - $columnIsVarbinary = $column->getType() === "VARBINARY"; - - if ($columnIsVarbinary === false) { - throw new \Exception("Encrypted columns must be of type VARBINARY. " . + if ($column->isLobType() === false) { + throw new \Exception("Encrypted columns must be of a binary type. " . "Encrypted column '{$column->getName()}' of type '{$column->getType()}' found. " . "Revise your schema."); } - } - if (static::encryptedColumnsDeclarationExists($script) === false) { - $insertLocation = strpos($script, ";", strpos($script, "const TABLE_NAME")) + 1; - static::insertEncryptedColumnsDeclaration($script, $insertLocation); - static::insertEncryptedColumnNameAccessMethods($script); - } - - foreach ($this->getColumnRealNames() as $realColumnName) { + $realColumnName = $this->createRealColumnName($columnName, $table->getName()); static::insertEncryptedColumnName($script, $realColumnName); - if ($this->isSearchable() === true || strtolower($this->isSearchable()) === 'true') { + if ($this->isSearchable($columnName, $searchableColumnNames)) { static::insertSearchableEncryptedColumnName($script, $realColumnName); } } @@ -66,20 +68,27 @@ public function tableMapFilter(&$script) /** * @param string $script + * * @return void */ public function objectFilter(&$script) { - $phpColumnNames = $this->getColumnPhpNames(); + $columnNames = $this->getColumnNames(); + $searchableColumnNames = $this->getSearchableColumnNames(); + $table = $this->getTable(); - foreach ($phpColumnNames as $columnPhpName) { - $this->addEncryptionToSetter($script, $columnPhpName); + foreach ($columnNames as $columnName) { + $isSearchable = $this->isSearchable($columnName, $searchableColumnNames); + $columnPhpName = $table->getColumn($columnName)->getPhpName(); + + $this->addEncryptionToSetter($script, $columnPhpName, $isSearchable); $this->addDecryptionToGetter($script, $columnPhpName); } } /** * @param string $script + * * @return void */ public function queryFilter(&$script) @@ -92,7 +101,6 @@ public function queryFilter(&$script) public function addUsingOperator($p1, $value = null, $operator = null, $preferColumnCondition = true) { - /** @var StudentTableMap $tableMap */ $tableMap = $this->getTableMap(); /** @var boolean $isCriterion */ @@ -131,71 +139,114 @@ public function addUsingOperator($p1, $value = null, $operator = null, $preferCo } /** - * @return string[] + * @param string $script + * @param string $realColumnName + * + * @return void */ - protected function getColumnNames() + public static function insertEncryptedColumnName(&$script, $realColumnName) { - $columnNames = []; - foreach ($this->getParameters() as $key => $columnName) { - if (strpos($key, "column_name") !== false && empty($columnName) !== true) { - $columnNames[] = $columnName; - } - } - return $columnNames; + $insertContent = "\n '$realColumnName', "; + + $insertLocation = strpos($script, '$encryptedColumns = array(') + strlen('$encryptedColumns = array('); + $script = substr_replace($script, $insertContent, $insertLocation, 0); } /** - * @return string[] + * @param string $script + * @param string $realColumnName + * + * @return void */ - protected function getColumnPhpNames() + public static function insertSearchableEncryptedColumnName(&$script, $realColumnName) { - $table = $this->getTable(); + $insertContent = "\n '$realColumnName', "; - return array_map( - function ($columnName) use ($table) { - return $table->getColumn($columnName)->getPhpName(); - }, - $this->getColumnNames() - ); + $insertLocation = strpos($script, '$encryptedSearchableColumns = array(') + + strlen('$encryptedSearchableColumns = array('); + + $script = substr_replace($script, $insertContent, $insertLocation, 0); } /** * @return string[] */ - protected function getColumnRealNames() + protected function getColumnNames(): array { - $tableName = $this->getTable()->getName(); + return $this->getParameterValuesByPrefix('column_name'); + } - return array_map( - function ($columnName) use ($tableName) { - return "$tableName.$columnName"; - }, - $this->getColumnNames() - ); + /** + * @return string[] + */ + protected function getSearchableColumnNames(): array + { + return $this->getParameterValuesByPrefix('column_name_searchable'); + } + + /** + * @param string $prefix + * + * @return array + */ + protected function getParameterValuesByPrefix(string $prefix): array + { + $parameterValues = []; + foreach ($this->getParameters() as $parameterName => $parameterValue) { + if (strpos($parameterName, $prefix) !== false && !empty($parameterValue)) { + $parameterValues[] = $parameterValue; + } + } + return $parameterValues; } /** + * @param string $columnName + * @param string $tableName + * + * @return string + */ + protected function createRealColumnName(string $columnName, string $tableName): string + { + return sprintf('%s.%s', $tableName, $columnName); + } + + /** + * @param string $columnName + * @param array $searchableColumnNames + * * @return boolean */ - protected function isSearchable() + protected function isSearchable(string $columnName, array $searchableColumnNames = []): bool { - return $this->getParameter('searchable'); + $searchableParameter = $this->getParameter('searchable'); + + if ($searchableParameter === true + || strtolower($searchableParameter) === 'true' + || array_search($columnName, $searchableColumnNames) !== false + ) { + return true; + } + + return false; } /** * @param string $script + * * @return boolean */ - protected static function encryptedColumnsDeclarationExists($script) + protected static function encryptedColumnsDeclarationExists(string $script): bool { return strpos($script, 'protected static $encryptedColumns') !== false; } /** * @param string $script + * * @return void */ - protected static function insertEncryptedColumnNameAccessMethods(&$script) + protected static function insertEncryptedColumnNameAccessMethods(string &$script): void { $useString = <<<'EOT' @@ -230,11 +281,11 @@ public static function isEncryptedSearchableColumnName($columnName) /** * @param string $script * @param integer $position + * * @return void */ - protected static function insertEncryptedColumnsDeclaration(&$script, $position) + protected static function insertEncryptedColumnsDeclaration(string &$script, int $position): void { - $content = <<<'EOT' @@ -254,40 +305,14 @@ protected static function insertEncryptedColumnsDeclaration(&$script, $position) $script = substr_replace($script, $content, $position, 0); } - /** - * @param string $script - * @param string $realColumnName - * @return void - */ - public static function insertEncryptedColumnName(&$script, $realColumnName) - { - $insertContent = "\n '$realColumnName', "; - - $insertLocation = strpos($script, '$encryptedColumns = array(') + strlen('$encryptedColumns = array('); - $script = substr_replace($script, $insertContent, $insertLocation, 0); - } - - /** - * @param string $script - * @param string $realColumnName - * @return void - */ - public static function insertSearchableEncryptedColumnName(&$script, $realColumnName) - { - $insertContent = "\n '$realColumnName', "; - - $insertLocation = strpos($script, '$encryptedSearchableColumns = array(') - + strlen('$encryptedSearchableColumns = array('); - - $script = substr_replace($script, $insertContent, $insertLocation, 0); - } - /** * @param string $script * @param string $columnPhpName + * @param bool $isSearchable + * * @return void */ - protected function addEncryptionToSetter(&$script, $columnPhpName) + protected function addEncryptionToSetter(string &$script, string $columnPhpName, bool $isSearchable): void { $setterLocation = strpos($script, "set$columnPhpName"); @@ -295,7 +320,7 @@ protected function addEncryptionToSetter(&$script, $columnPhpName) $length = strpos($script, ")", $setterLocation) - $start; $variableName = substr($script, $start, $length); - $encryptionMethod = $this->isSearchable() ? "deterministicEncrypt" : "encrypt"; + $encryptionMethod = $isSearchable ? "deterministicEncrypt" : "encrypt"; $content = <<columns = [ - "VarBinaryColumn1" => new MockColumn("VarBinaryColumn1", "VARBINARY"), - "VarBinaryColumn2" => new MockColumn("VarBinaryColumn2", "VARBINARY"), - "NotVarBinaryColumn" => new MockColumn("NotVarBinaryColumn", "NOTVARBINARY") - ]; - - parent::__construct(); - } - - protected function normalizeWhitespace($string) - { - $string = trim($string); - $string = str_replace("\r", "", $string); - - $string = join("\n", array_map("rtrim", explode("\n", $string))); - - return $string; - } - - public function testObjectFilter() - { - $behavior = new MockEncryptionBehavior( - $this->columns, - [ - 'column_name' => "VarBinaryColumn1", - 'searchable' => false - ] - ); - - $behavior->objectFilter($this->objectFilterInput); - - $this->assertEquals( - $this->normalizeWhitespace($this->objectFilterExpected), - $this->normalizeWhitespace($this->objectFilterInput) - ); - } - - public function testMapFilter() - { - $behavior = new MockEncryptionBehavior( - $this->columns, - [ - 'column_name' => "VarBinaryColumn1", - 'searchable' => false - ] - ); - - // Run table map filter once, and an encrypted columns declaration is created - $behavior->tableMapFilter($this->mapFilterInput); - $this->assertEquals( - $this->normalizeWhitespace($this->mapFilterExpected), - $this->normalizeWhitespace($this->mapFilterInput) - ); - - // Run it twice, and the new column name is inserted beside the old - $behavior->tableMapFilter($this->mapFilterInput); - $this->assertEquals( - $this->normalizeWhitespace($this->mapFilterExpectedSecond), - $this->normalizeWhitespace($this->mapFilterInput) - ); - } - /** - * @expectedException \Exception - * @expectedExceptionMessageRegExp #Encrypted columns must be of type VARBINARY.*# + * @var string */ - public function testBehaviorExceptionOnNonVarBinaryColumn() - { - $behavior = new MockEncryptionBehavior( - $this->columns, - [ - 'column_name' => "NotVarBinaryColumn", - 'searchable' => false, - ] - ); - - // Run table map filter once, and an encrypted columns declaration is created - $input = ""; - $behavior->tableMapFilter($input); - } - protected $objectFilterInput = <<<'EOT' public function getVarBinaryColumn1() { @@ -119,6 +40,9 @@ public function setVarBinaryColumn1($v) } // setVarBinaryColumn1() EOT; + /** + * @var string + */ protected $objectFilterExpected = <<<'EOT' public function getVarBinaryColumn1() { @@ -151,6 +75,9 @@ public function setVarBinaryColumn1($v) } // setVarBinaryColumn1() EOT; + /** + * @var string + */ protected $mapFilterInput = <<columns = [ + "VarBinaryColumn1" => new MockColumn("VarBinaryColumn1", "VARBINARY"), + "VarBinaryColumn2" => new MockColumn("VarBinaryColumn2", "VARBINARY"), + "BlobColumn" => new MockColumn("BlobColumn", "BLOB"), + "LongVarBinaryColumn" => new MockColumn("BlobColumn", "LONGVARBINARY"), + "NotVarBinaryColumn" => new MockColumn("NotVarBinaryColumn", "NOTVARBINARY"), + ]; + + parent::setUp(); + } + + /** + * @return void + */ + public function testObjectFilter(): void + { + $behavior = new MockEncryptionBehavior( + $this->columns, + [ + 'column_name' => "VarBinaryColumn1", + 'searchable' => false + ] + ); + + $behavior->objectFilter($this->objectFilterInput); + + $this->assertEquals( + $this->normalizeWhitespace($this->objectFilterExpected), + $this->normalizeWhitespace($this->objectFilterInput) + ); + } + + /** + * @throws \Exception + * + * @return void + */ + public function testMapFilter(): void + { + $behavior = new MockEncryptionBehavior( + $this->columns, + [ + 'column_name' => "VarBinaryColumn1", + 'searchable' => false + ] + ); + + // Run table map filter once, and an encrypted columns declaration is created + $behavior->tableMapFilter($this->mapFilterInput); + $this->assertEquals( + $this->normalizeWhitespace($this->mapFilterExpected), + $this->normalizeWhitespace($this->mapFilterInput) + ); + + // Run it twice, and the new column name is inserted beside the old + $behavior->tableMapFilter($this->mapFilterInput); + $this->assertEquals( + $this->normalizeWhitespace($this->mapFilterExpectedSecond), + $this->normalizeWhitespace($this->mapFilterInput) + ); + } + + /** + * @throws \Exception + * + * @return void + */ + public function testBehaviorExceptionOnNonVarBinaryColumn(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Encrypted columns must be of a binary type. Encrypted column \'NotVarBinaryColumn\' of type \'NOTVARBINARY\' found. Revise your schema.'); + + $behavior = new MockEncryptionBehavior( + $this->columns, + [ + 'column_name' => "NotVarBinaryColumn", + 'searchable' => false, + ] + ); + + // Run table map filter once, and an encrypted columns declaration is created + $input = ""; + $behavior->tableMapFilter($input); + } + + /** + * @param $string + * + * @return string + */ + protected function normalizeWhitespace($string): string + { + $string = trim($string); + $string = str_replace("\r", "", $string); + + $string = join("\n", array_map("rtrim", explode("\n", $string))); + + return $string; + } } diff --git a/test/CipherTest.php b/test/CipherTest.php index e929ede..1891335 100644 --- a/test/CipherTest.php +++ b/test/CipherTest.php @@ -2,37 +2,55 @@ namespace Athens\Encryption\Test; -use PHPUnit_Framework_TestCase; - +use PHPUnit\Framework\TestCase; use Athens\Encryption\Cipher; -class CipherTest extends PHPUnit_Framework_TestCase +class CipherTest extends TestCase { - /** - * @expectedException Exception - * @expectedExceptionMessageRegExp #called before initialization.*# + * @throws \Exception + * + * @return void */ - public function testGetInstanceBeforeCreate() + public function testGetInstanceBeforeCreate():void { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('called before initialization.'); + Cipher::getInstance(); } - public function testCipherCreation() + /** + * @throws \Exception + * + * @return void + */ + public function testCipherCreation(): void { Cipher::createInstance("blaksjdfoiuwer"); + + $this->assertTrue(true); } /** - * @expectedException Exception - * @expectedExceptionMessageRegExp #Only one cipher instance may be created.*# + * @throws \Exception + * + * @return void */ - public function testCreateInstanceTwice() + public function testCreateInstanceTwice(): void { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Only one cipher instance may be created.'); + Cipher::createInstance("blaksjdfoiuwer"); } - public function testEncrypt() + /** + * @throws \Exception + * + * @return void + */ + public function testEncrypt(): void { $plainText = "plaintext"; @@ -43,7 +61,12 @@ public function testEncrypt() $this->assertNotEquals($encryptedText1, $encryptedText2); } - public function testEncryptDecrypt() + /** + * @throws \Exception + * + * @return void + */ + public function testEncryptDecrypt(): void { $plainText = "plaintext"; diff --git a/test/mock/MockColumn.php b/test/mock/MockColumn.php index 6abd902..b86123a 100644 --- a/test/mock/MockColumn.php +++ b/test/mock/MockColumn.php @@ -8,6 +8,8 @@ namespace Athens\Encryption\Test\Mock; +use Propel\Generator\Model\PropelTypes; + class MockColumn { protected $phpName; @@ -33,4 +35,9 @@ public function getName() { return $this->phpName; } + + public function isLobType() + { + return PropelTypes::isLobType($this->getType()); + } } diff --git a/test/mock/MockEncryptionBehavior.php b/test/mock/MockEncryptionBehavior.php index 2341dd8..65581a1 100644 --- a/test/mock/MockEncryptionBehavior.php +++ b/test/mock/MockEncryptionBehavior.php @@ -13,7 +13,6 @@ public function __construct($columns, $parameters) { $this->parameters = $parameters; $this->table = new MockTable($columns); - parent::__construct(); } public function getTable() diff --git a/test/phpunit.xml b/test/phpunit.xml index 2f5b22d..1840034 100644 --- a/test/phpunit.xml +++ b/test/phpunit.xml @@ -10,4 +10,4 @@ ../src/ - \ No newline at end of file +