Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support to encrypt and decrypt DateTime, JSON and array data types #24

Merged
merged 3 commits into from Mar 2, 2024

Conversation

r3hp1c
Copy link
Collaborator

@r3hp1c r3hp1c commented Jul 26, 2023

No description provided.

@r3hp1c
Copy link
Collaborator Author

r3hp1c commented Jul 26, 2023

@cs-akash-jarad I do not just want to merge this one, it is a little bigger than the others I did. Please review and merge if you are happy.

@cs-akash-jarad
Copy link

sure will check. i ll be able to review and merge this weekend.

@r3hp1c
Copy link
Collaborator Author

r3hp1c commented Sep 29, 2023

@cs-akash-jarad any chance that you will be able to get to this soon?

Copy link

@Zombaya Zombaya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea to support other types than just strings.

What I would change though, is leveraging \Doctrine\DBAL\Types\Type::getType() and it's conversions from and to SQL-representation (which usually are strings) so that we can support all types of already known doctrine-types, including custom ones defined by our users.

This way we can avoid doing the conversion from PHP to string and back ourselves.

The only thing we would need to do, is choose an appropriate Platform to use for our conversions.

An demo-implementation can be found somewhere in my code-review-comments.

I've also noted some places where I'd do some stricter compares.

Comment on lines 270 to 281
if ($encryptType == 'datetime')
{
$usedValue = $value->format (self::DATETIME_FORMAT);
}
elseif ($encryptType == 'json')
{
$usedValue = json_encode($value);
}
elseif ($encryptType == 'array')
{
$usedValue = serialize($value);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use strict-compare here:

Suggested change
if ($encryptType == 'datetime')
{
$usedValue = $value->format (self::DATETIME_FORMAT);
}
elseif ($encryptType == 'json')
{
$usedValue = json_encode($value);
}
elseif ($encryptType == 'array')
{
$usedValue = serialize($value);
}
if ($encryptType === 'datetime')
{
$usedValue = $value->format (self::DATETIME_FORMAT);
}
elseif ($encryptType === 'json')
{
$usedValue = json_encode($value);
}
elseif ($encryptType === 'array')
{
$usedValue = serialize($value);
}

Comment on lines 295 to 310
if ($encryptType == 'string')
{
$this->pac->setValue($entity, $refProperty->getName(), $currentPropValue);
}
elseif ($encryptType == 'datetime')
{
$this->pac->setValue($entity, $refProperty->getName(), DateTime::createFromFormat(self::DATETIME_FORMAT, $currentPropValue));
}
elseif ($encryptType == 'json')
{
$this->pac->setValue($entity, $refProperty->getName(), json_decode($currentPropValue, true));
}
elseif ($encryptType == 'array')
{
$this->pac->setValue($entity, $refProperty->getName(), unserialize($currentPropValue));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, using strict compare:

Suggested change
if ($encryptType == 'string')
{
$this->pac->setValue($entity, $refProperty->getName(), $currentPropValue);
}
elseif ($encryptType == 'datetime')
{
$this->pac->setValue($entity, $refProperty->getName(), DateTime::createFromFormat(self::DATETIME_FORMAT, $currentPropValue));
}
elseif ($encryptType == 'json')
{
$this->pac->setValue($entity, $refProperty->getName(), json_decode($currentPropValue, true));
}
elseif ($encryptType == 'array')
{
$this->pac->setValue($entity, $refProperty->getName(), unserialize($currentPropValue));
}
if ($encryptType === 'string')
{
$this->pac->setValue($entity, $refProperty->getName(), $currentPropValue);
}
elseif ($encryptType === 'datetime')
{
$this->pac->setValue($entity, $refProperty->getName(), DateTime::createFromFormat(self::DATETIME_FORMAT, $currentPropValue));
}
elseif ($encryptType === 'json')
{
$this->pac->setValue($entity, $refProperty->getName(), json_decode($currentPropValue, true));
}
elseif ($encryptType === 'array')
{
$this->pac->setValue($entity, $refProperty->getName(), unserialize($currentPropValue));
}




public function __construct(?string $type = 'string')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not make this argument nullable. You can now instantiate this class with null, which will immediately throw an InvalidArgumentException.

Making it non-nullable avoids this problem.

We could even help IDEs by adding a docblock to signal only to allow certain values:

        /**
	 * @param 'string'|'datetime'|'json'|'array' $type
	 */
        public function __construct(?string $type = 'string')

PHPStan is at least able to deduct the possible values: https://phpstan.org/r/c54a63fb-8a9d-41ba-9a6c-655a39e7711a


public function __construct(?string $type = 'string')
{
if (!in_array ($type, self::ALLOWED_TYPES))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd do a strict test:

Suggested change
if (!in_array ($type, self::ALLOWED_TYPES))
if (!in_array($type, self::ALLOWED_TYPES, true))

}
elseif ($encryptType == 'array')
{
$this->pac->setValue($entity, $refProperty->getName(), unserialize($currentPropValue));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would limit this as to not unserialize any classes by setting the allowed_classes-option to an empty array:

Suggested change
$this->pac->setValue($entity, $refProperty->getName(), unserialize($currentPropValue));
$this->pac->setValue($entity, $refProperty->getName(), unserialize($currentPropValue,['allowed_classes' => []]));

This to avoid any problems relating to serializing and unserialing classes.

If we need to support that, I would add allowedClasses as an extra parameter to the Encrypted-attribute and limit it to that.

}
elseif ($encryptType == 'datetime')
{
$this->pac->setValue($entity, $refProperty->getName(), DateTime::createFromFormat(self::DATETIME_FORMAT, $currentPropValue));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using this method of storing date-times, does lose the timezone information a bit. I added an extra unittest locally to verify this:

abstract class AbstractDoctrineEncryptSubscriberTestCase extends AbstractFunctionalTestCase
{
public function testEntityWithDateTimePropertiesIsTimezoneResistant()
    {
        date_default_timezone_set('America/New_York');

        $user = new DateTimeJsonArrayTarget();
        // Use fixed time to avoid flaky tests based on current time
        $datetime = new DateTime('2022-06-01 12:00:00', new \DateTimeZone('Europe/Brussels'));
        $user->setDate(clone $datetime);
        $this->entityManager->persist($user);
        $this->entityManager->flush();

        // Clear all data from the entity-manager
        $this->entityManager->clear();
        // Set default timezone to something else
        date_default_timezone_set('Asia/Bangkok');

        // Refetch the user-entity
        $user = $this->entityManager->find(DateTimeJsonArrayTarget::class, $user->getId());

        $entityDate = $user->getDate();
        $this->assertEquals($datetime, $entityDate);
        $this->assertNotFalse($entityDate->getTimezone());
        $this->assertEquals('Europe/Brussels',$entityDate->getTimezone()->getName());
        $this->assertEquals($datetime->getTimestamp(), $entityDate->getTimestamp());
    }
}

Possible solution

Perhaps we can leverage \Doctrine\DBAL\Types\... to do the conversion for us?
I believe all types covert from and to string-values, which would allow us to support basically all types known to doctrine, even the custom ones defined by users of this library.

That way we also do not need to worry about different types of conversion ourselves.

Futher investigation

After looking into the default doctrine-types, the default doctrine-types are also not timezone-aware and will also lose timezone-information.

Comment on lines 261 to 311
$encryptType = $this->getEncryptedPropertyType($refProperty);
if ($encryptType) {
$rootEntityName = $entityManager->getClassMetadata(get_class($entity))->rootEntityName;

$pac = PropertyAccess::createPropertyAccessor();
$value = $pac->getValue($entity, $refProperty->getName());
if ($encryptorMethod === 'decrypt') {
if (!is_null($value) and !empty($value)) {
$value = $this->pac->getValue($entity, $refProperty->getName());
if (!is_null($value) and !empty($value)) {
if ($isEncryptOperation) {
// Default string value
$usedValue = $value;
if ($encryptType == 'datetime')
{
$usedValue = $value->format (self::DATETIME_FORMAT);
}
elseif ($encryptType == 'json')
{
$usedValue = json_encode($value);
}
elseif ($encryptType == 'array')
{
$usedValue = serialize($value);
}

if (isset($this->cachedDecryptions[$rootEntityName][spl_object_id($entity)][$refProperty->getName()][$usedValue])) {
$this->pac->setValue($entity, $refProperty->getName(), $this->cachedDecryptions[$rootEntityName][spl_object_id($entity)][$refProperty->getName()][$usedValue]);
} elseif (substr($usedValue, -strlen(self::ENCRYPTION_MARKER)) != self::ENCRYPTION_MARKER) {
$this->encryptCounter++;
$currentPropValue = $this->encryptor->encrypt($usedValue).self::ENCRYPTION_MARKER;
$this->pac->setValue($entity, $refProperty->getName(), $currentPropValue);
}
} else {
if (substr($value, -strlen(self::ENCRYPTION_MARKER)) == self::ENCRYPTION_MARKER) {
$this->decryptCounter++;
$currentPropValue = $this->encryptor->decrypt(substr($value, 0, -5));
$pac->setValue($entity, $refProperty->getName(), $currentPropValue);
$this->cachedDecryptions[$rootEntityName][spl_object_id($entity)][$refProperty->getName()][$currentPropValue] = $value;
}
}
} else {
if (!is_null($value) and !empty($value)) {
if (isset($this->cachedDecryptions[$rootEntityName][spl_object_id($entity)][$refProperty->getName()][$value])) {
$pac->setValue($entity, $refProperty->getName(), $this->cachedDecryptions[$rootEntityName][spl_object_id($entity)][$refProperty->getName()][$value]);
} elseif (substr($value, -strlen(self::ENCRYPTION_MARKER)) != self::ENCRYPTION_MARKER) {
$this->encryptCounter++;
$currentPropValue = $this->encryptor->encrypt($value).self::ENCRYPTION_MARKER;
$pac->setValue($entity, $refProperty->getName(), $currentPropValue);
if ($encryptType == 'string')
{
$this->pac->setValue($entity, $refProperty->getName(), $currentPropValue);
}
elseif ($encryptType == 'datetime')
{
$this->pac->setValue($entity, $refProperty->getName(), DateTime::createFromFormat(self::DATETIME_FORMAT, $currentPropValue));
}
elseif ($encryptType == 'json')
{
$this->pac->setValue($entity, $refProperty->getName(), json_decode($currentPropValue, true));
}
elseif ($encryptType == 'array')
{
$this->pac->setValue($entity, $refProperty->getName(), unserialize($currentPropValue));
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a proof of concept, I tried leveraging the doctrine-type-conversion and it worked! Perhaps this could be a better solution than converting the data ourselves:

$encryptDbalType = \Doctrine\DBAL\Types\Type::getType($encryptType);
$platform = new MySQL80Platform();
$rootEntityName = $entityManager->getClassMetadata(get_class($entity))->rootEntityName;

$value = $this->pac->getValue($entity, $refProperty->getName());
if (!is_null($value) and !empty($value)) {
    if ($isEncryptOperation) {
        // Convert to a string using doctrine-type
        $usedValue = $encryptDbalType->convertToDatabaseValue($value, $platform);

        if (isset($this->cachedDecryptions[$rootEntityName][spl_object_id($entity)][$refProperty->getName()][$usedValue])) {
            $this->pac->setValue($entity, $refProperty->getName(), $this->cachedDecryptions[$rootEntityName][spl_object_id($entity)][$refProperty->getName()][$usedValue]);
        } elseif (substr($usedValue, -strlen(self::ENCRYPTION_MARKER)) != self::ENCRYPTION_MARKER) {
            $this->encryptCounter++;
            $currentPropValue = $this->encryptor->encrypt($usedValue).self::ENCRYPTION_MARKER;
            $this->pac->setValue($entity, $refProperty->getName(), $currentPropValue);
        }
    } else {
        if (substr($value, -strlen(self::ENCRYPTION_MARKER)) == self::ENCRYPTION_MARKER) {
            $this->decryptCounter++;
            $currentPropValue = $this->encryptor->decrypt(substr($value, 0, -5));
            $this->cachedDecryptions[$rootEntityName][spl_object_id($entity)][$refProperty->getName()][$currentPropValue] = $value;

            // Convert from a string to the PHP-type again using dbal-type
            $actualValue = $encryptDbalType->convertToPHPValue($currentPropValue, $platform);
            $this->pac->setValue($entity, $refProperty->getName(),$actualValue);
        }
    }
}

* @Target("PROPERTY")
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
class Encrypted implements Annotation
final class Encrypted implements Annotation
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making this final could theoretically break applications using our library. I don't mind making this final though, as we would expect this to not be extended.

@r3hp1c
Copy link
Collaborator Author

r3hp1c commented Dec 28, 2023

@Zombaya I implemented your suggestion of using \Doctrine\DBAL\Types\Type::getType() instead of doing the conversions directly. Note that I used the entity manager getConnection and the connection getDatabasePlatform instead of always just using the MySql Platform.

If you approve, then we could merge and finish your pull request to add support for Symfony 7 #28 and the non-breaking changes in #27.

Once we have all those changes done, I want to release version 5.3.2 and create 2 branches 5.x and 6.x. In the 6.x branch I would like to start implementing all the breaking changes. I do have an idea I would like to try that I think could possibly make the bundle a little faster and use less memory, but will need to get there to try it out and confirm.

@Zombaya
Copy link

Zombaya commented Feb 14, 2024

Firstly, sorry to go so long without responding. We had a very hectic period before Christmas and after the holidays and I had little energy left after work to work on open source.

The code looks good to me.

I would however rethink fetching the platform from the connection. I feel it would be better to use a fixed platform so that:

  1. a user could migrate from database-platform. If the encoding/decoding differs between the original platform and the new platform, there will be data-corruption
  2. if a bug ever pops up regarding this type of encoding/decoding, we can be pretty certain which platform was used and how the data was encoded.

But code-wise, everything looks good to me.

@r3hp1c
Copy link
Collaborator Author

r3hp1c commented Feb 14, 2024

Firstly, sorry to go so long without responding. We had a very hectic period before Christmas and after the holidays and I had little energy left after work to work on open source.

The code looks good to me.

I would however rethink fetching the platform from the connection. I feel it would be better to use a fixed platform so that:

  1. a user could migrate from database-platform. If the encoding/decoding differs between the original platform and the new platform, there will be data-corruption
  2. if a bug ever pops up regarding this type of encoding/decoding, we can be pretty certain which platform was used and how the data was encoded.

But code-wise, everything looks good to me.

@Zombaya have invited you again. Thanks for the willingness to assist and keep the bundle alive.

I see your point about the possible data corruption between database platforms, but will that not possibly be an issue as well if we force the use of a mismatching platform?

@Zombaya
Copy link

Zombaya commented Feb 16, 2024

I don't think the will be a problem of mismatching platform since from doctrine's point of view, we are storing a string.

The process is:

  1. random encodable object
  2. string (encoded using fixed platform)
  3. string (encrypted)
  4. pass to doctrine to store in DB

After which doctrine decides on how to store that finally encrypted string in the DB.

@r3hp1c
Copy link
Collaborator Author

r3hp1c commented Feb 27, 2024

@Zombaya think we should merge #28 before we merge this one as Symfony 7 support is probably more important than adding support for new types.
I did make the change to use a fixed platform as suggested.

@r3hp1c r3hp1c merged commit aa60745 into DoctrineEncryptBundle:master Mar 2, 2024
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants