diff --git a/README.md b/README.md index 0de6f06..86f4022 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +#Doctrine Encryted Column +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/carnage/doctrine-encrypted-column/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/carnage/doctrine-encrypted-column/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/carnage/doctrine-encrypted-column/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/carnage/doctrine-encrypted-column/?branch=master) + # Motivation Currently there are about a dozen encrypted column extensions for doctrine. None of them are very well implemented and are diff --git a/composer.json b/composer.json index 54e9663..39c26a0 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "doctrine/orm": "^2.5", "ocramius/proxy-manager": "^1.0.2", "paragonie/halite": "^2.0", - "psr/container": "^1.0" + "psr/container": "^1.0", + "phpseclib/phpseclib": "~2.0" }, "require-dev": { "phpunit/phpunit":"^5" diff --git a/example/bootstrap.php b/example/bootstrap.php index 51bddfd..cfe5e53 100644 --- a/example/bootstrap.php +++ b/example/bootstrap.php @@ -7,7 +7,7 @@ // Create a simple "default" Doctrine ORM configuration for Annotations $isDevMode = true; -$config = Setup::createAnnotationMetadataConfiguration(array(__DIR__."/entities"), $isDevMode, null,null,false); +$config = Setup::createAnnotationMetadataConfiguration(array(__DIR__ . "/entities"), $isDevMode, null, null, false); // database configuration parameters $conn = array( diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a6aef61..c2e0cb4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,8 +2,16 @@ - - ./test + + ./test/Functional + + + ./test/Migration + + + ./src + + diff --git a/src/Dbal/EncryptedColumnLegacySupport.php b/src/Dbal/EncryptedColumnLegacySupport.php new file mode 100644 index 0000000..1e416b1 --- /dev/null +++ b/src/Dbal/EncryptedColumnLegacySupport.php @@ -0,0 +1,114 @@ +encryptionService = $encryptionService; + } + + public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) + { + return $platform->getClobTypeDeclarationSQL($fieldDeclaration); + } + + public function requiresSQLCommentHint(AbstractPlatform $platform) + { + return true; + } + + public function getName() + { + return self::ENCRYPTED; + } + + public function convertToPHPValue($value, AbstractPlatform $platform) + { + if ($value === null) { + return null; + } + + try { + $decoded = $this->decodeJson($value); + } catch (ConversionException $e) { + //The data wasn't in the format we expected, assume it is legacy data which needs converting + //Drop in some defaults to allow the library to handle it. + $decoded = [ + 'data' => $value, + 'classname' => ValueHolder::class, + 'serializer' => 'legacy', + 'encryptor' => 'legacy' + ]; + } + + return $this->encryptionService->decryptField(EncryptedColumnVO::fromArray($decoded)); + } + + public function convertToDatabaseValue($value, AbstractPlatform $platform) + { + if ($value === null) { + return null; + } + + return json_encode($this->encryptionService->encryptField($value)); + } + + /** + * Based on: https://github.com/schmittjoh/serializer/blob/master/src/JMS/Serializer/JsonDeserializationVisitor.php + * + * @param $value + * @return mixed + * @throws ConversionException + */ + private function decodeJson($value) + { + $decoded = json_decode($value, true); + + switch (json_last_error()) { + case JSON_ERROR_NONE: + if (!is_array($decoded)) { + throw ConversionException::conversionFailed($value, 'Json was not an array'); + } + return $decoded; + case JSON_ERROR_DEPTH: + throw ConversionException::conversionFailed($value, 'Could not decode JSON, maximum stack depth exceeded.'); + case JSON_ERROR_STATE_MISMATCH: + throw ConversionException::conversionFailed($value, 'Could not decode JSON, underflow or the nodes mismatch.'); + case JSON_ERROR_CTRL_CHAR: + throw ConversionException::conversionFailed($value, 'Could not decode JSON, unexpected control character found.'); + case JSON_ERROR_SYNTAX: + throw ConversionException::conversionFailed($value, 'Could not decode JSON, syntax error - malformed JSON.'); + case JSON_ERROR_UTF8: + throw ConversionException::conversionFailed($value, 'Could not decode JSON, malformed UTF-8 characters (incorrectly encoded?)'); + default: + throw ConversionException::conversionFailed($value, 'Could not decode Json'); + } + } +} diff --git a/src/Encryptor/LegacyEncryptor.php b/src/Encryptor/LegacyEncryptor.php new file mode 100644 index 0000000..7de0f83 --- /dev/null +++ b/src/Encryptor/LegacyEncryptor.php @@ -0,0 +1,43 @@ +secret = $secret; + } + + public function encrypt($data) + { + throw new PopArtPenguinException(); + } + + public function decrypt($data) + { + $cipher = new Rijndael(Base::MODE_ECB); + $cipher->setBlockLength(256); + $cipher->setKey($this->secret); + $cipher->padding = false; + + return trim($cipher->decrypt(base64_decode($data))); + } + + public function getIdentifier(): IdentityInterface + { + return new EncryptorIdentity(self::IDENTITY); + } +} \ No newline at end of file diff --git a/src/Exception/PopArtPenguinException.php b/src/Exception/PopArtPenguinException.php new file mode 100644 index 0000000..6921967 --- /dev/null +++ b/src/Exception/PopArtPenguinException.php @@ -0,0 +1,13 @@ +needsReencryption($this->encryptor->getIdentifier(), $this->serializer->getIdentifier()) ) { - return $original; + return $original; } } @@ -109,7 +109,7 @@ private function createInitializer(EncryptedColumnVO $value): \Closure $serializer = $this->serializers->get($value->getSerializerIdentifier()->toString()); $encryptor = $this->encryptors->get($value->getEncryptorIdentifier()->toString()); - return function (& $wrappedObject, LazyLoadingInterface $proxy, $method, array $parameters, & $initializer) use ($serializer, $encryptor, $value) { + return function(& $wrappedObject, LazyLoadingInterface $proxy, $method, array $parameters, & $initializer) use ($serializer, $encryptor, $value) { $initializer = null; $wrappedObject = $serializer->unserialize($encryptor->decrypt($value->getData())); diff --git a/src/Setup.php b/src/Setup.php new file mode 100644 index 0000000..7dd1bf1 --- /dev/null +++ b/src/Setup.php @@ -0,0 +1,99 @@ +enableLegacy) { + $this->doRegisterLegacy($em); + } else { + $this->doRegister($em); + } + } + + public function enableLegacy(string $legacyKey) + { + $this->enableLegacy = true; + $this->legacyKey = $legacyKey; + return $this; + } + + public function withKeyPath(string $keypath) + { + $this->keyPath = $keypath; + return $this; + } + + private function buildEncryptionService(): EncryptionService + { + $encryptors = self::buildEncryptorsContainer(); + $serializers = self::buildSerilaizerContainer(); + return new EncryptionService( + $encryptors->get(HaliteEncryptor::IDENTITY), + $serializers->get(PhpSerializer::IDENTITY), + $encryptors, + $serializers + ); + } + + private function buildEncryptorsContainer(): VersionedContainer + { + $services = [new HaliteEncryptor($this->keyPath)]; + if ($this->enableLegacy) { + $services[] = new LegacyEncryptor($this->legacyKey); + } + //@TODO add legacy encryptor, throw exceptions if required keys aren't specified + return new VersionedContainer(...$services); + } + + private function buildSerilaizerContainer(): VersionedContainer + { + $services = [new PhpSerializer()]; + if ($this->enableLegacy) { + $services[] = new LegacySerializer(); + } + return new VersionedContainer(...$services); + } + + /** + * @param EntityManagerInterface $em + */ + private function doRegister(EntityManagerInterface $em) + { + EncryptedColumn::create($this->buildEncryptionService()); + $conn = $em->getConnection(); + $conn->getDatabasePlatform()->registerDoctrineTypeMapping( + EncryptedColumn::ENCRYPTED, + EncryptedColumn::ENCRYPTED + ); + } + + /** + * @param EntityManagerInterface $em + */ + private function doRegisterLegacy(EntityManagerInterface $em) + { + EncryptedColumnLegacySupport::create($this->buildEncryptionService()); + $conn = $em->getConnection(); + $conn->getDatabasePlatform()->registerDoctrineTypeMapping( + EncryptedColumnLegacySupport::ENCRYPTED, + EncryptedColumnLegacySupport::ENCRYPTED + ); + } +} \ No newline at end of file diff --git a/src/ValueObject/EncryptedColumn.php b/src/ValueObject/EncryptedColumn.php index c29ef03..ae2e52c 100644 --- a/src/ValueObject/EncryptedColumn.php +++ b/src/ValueObject/EncryptedColumn.php @@ -48,7 +48,7 @@ public static function fromArray(array $data) { // If an old version has saved data, these fields won't be available // Default to the only services available in V0.1 - if(!isset($data['serializer'])) { + if (!isset($data['serializer'])) { return new self( $data['classname'], $data['data'], diff --git a/src/ValueObject/ValueHolder.php b/src/ValueObject/ValueHolder.php new file mode 100644 index 0000000..e0e3bff --- /dev/null +++ b/src/ValueObject/ValueHolder.php @@ -0,0 +1,21 @@ +value = $value; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } +} diff --git a/test/Fixtures/CreditCardDetails.php b/test/Functional/Fixtures/CreditCardDetails.php similarity index 89% rename from test/Fixtures/CreditCardDetails.php rename to test/Functional/Fixtures/CreditCardDetails.php index 7e894f0..52ce96e 100644 --- a/test/Fixtures/CreditCardDetails.php +++ b/test/Functional/Fixtures/CreditCardDetails.php @@ -1,6 +1,6 @@ 'pdo_sqlite', @@ -40,7 +45,7 @@ public function setUp() self::$_em = EntityManager::create($conn, $config); - Configuration::register(self::$_em, __DIR__ . '/../Fixtures/enc.key'); + Configuration::register(self::$_em, __DIR__ . '/Fixtures/enc.key'); $schemaTool = new SchemaTool(self::$_em); diff --git a/test/Migration/FiftyOneSystemsTest.php b/test/Migration/FiftyOneSystemsTest.php new file mode 100644 index 0000000..23baa32 --- /dev/null +++ b/test/Migration/FiftyOneSystemsTest.php @@ -0,0 +1,84 @@ + 'pdo_sqlite', + 'path' => __DIR__ . "/Fixtures/Migrated/db.sqlite", + ); + + self::$_em = EntityManager::create($conn, $config); + + (new ECSetup()) + ->withKeyPath(__DIR__ . '/Fixtures/Migrated/enc.key') + ->enableLegacy(pack("H*", "dda8e5b978e05346f08b312a8c2eac03670bb5661097f8bc13212c31be66384c")) + ->register(self::$_em); + + $schemaTool = new SchemaTool(self::$_em); + + $classes = [ + self::$_em->getClassMetadata(Entity::class) + ]; + + $schemaTool->updateSchema($classes); + } + + $this->em = self::$_em; + } + + public function testRead() + { + /** @var Entity $entity */ + $entity = $this->em->find(Entity::class, 1); + $this->assertEquals('secret code', $entity->getSecretData()); + } + + public function testWrite() + { + /** @var Entity $entity */ + $entity = $this->em->find(Entity::class, 1); + $entity->setSecretData('top secret code'); + + $this->em->flush($entity); + $this->em->clear(); + + $entity = $this->em->find(Entity::class, 1); + $this->assertEquals('top secret code', $entity->getSecretData()); + } +} \ No newline at end of file diff --git a/test/Migration/Fixtures/51systems/Entity.php b/test/Migration/Fixtures/51systems/Entity.php new file mode 100644 index 0000000..f6bea5f --- /dev/null +++ b/test/Migration/Fixtures/51systems/Entity.php @@ -0,0 +1,59 @@ +id; + } + + /** + * @param mixed $id + */ + public function setId($id) + { + $this->id = $id; + } + + /** + * @return mixed + */ + public function getSecretData() + { + return $this->secret_data; + } + + /** + * @param mixed $secret_data + */ + public function setSecretData($secret_data) + { + $this->secret_data = $secret_data; + } +} \ No newline at end of file diff --git a/test/Migration/Fixtures/51systems/db.sqlite b/test/Migration/Fixtures/51systems/db.sqlite new file mode 100644 index 0000000..70f24a8 Binary files /dev/null and b/test/Migration/Fixtures/51systems/db.sqlite differ diff --git a/test/Migration/Fixtures/Migrated/Entity.php b/test/Migration/Fixtures/Migrated/Entity.php new file mode 100644 index 0000000..97d9584 --- /dev/null +++ b/test/Migration/Fixtures/Migrated/Entity.php @@ -0,0 +1,58 @@ +id; + } + + /** + * @param mixed $id + */ + public function setId($id) + { + $this->id = $id; + } + + /** + * @return mixed + */ + public function getSecretData() + { + return $this->secret_data->getValue(); + } + + /** + * @param mixed $secret_data + */ + public function setSecretData($secret_data) + { + $this->secret_data = new ValueHolder($secret_data); + } +} \ No newline at end of file diff --git a/test/Migration/Fixtures/Migrated/enc.key b/test/Migration/Fixtures/Migrated/enc.key new file mode 100644 index 0000000..3a4fda3 --- /dev/null +++ b/test/Migration/Fixtures/Migrated/enc.key @@ -0,0 +1 @@ +3140020116a8b2228171a56c19892cf5fa26cafa2d1ab2a2bd83f2cc7ca55e812bf65abdc429b99b9d70ad37a89f718a3b2adecb2b8f267e8487d75e2a48031b1ab4668beaa5a38d437ad3e6dc637e55d5a82e6d3647c84df40796be1d0893f6eb4971a4 \ No newline at end of file