Secure your sensible data using encryption in PHP
No matter which kind of data your project deals with, there is always sensitive data. For an user it could be his identity, bank detail, estate, store history. For a firm it could be security breach report, potential customer list...
If there is a data breach, troubles will be on the way. As you can't be confident in your security, encrypted these data is a good way to reduce impact by making these data unusable.
There are some existing library and bundle to use with Symfony and Doctrine that allow to encrypt and decrypt data on the fly with your database. ambta/doctrine-encrypt-bundle is what we used in our project.
These library use life cycle events of Doctrine ORM and it's have some drawbacks.
During entity hydratation, we use cpu to decrypt data even if we don't need to use this data. And it's make really difficult to use Doctrine DBAL because if you need decrypted data, you will have to do it yourself but the service to do it is not easy to use.
Once the field have been decrypted, if we call flush() method, the entity is not sync anymore with Unity Of Work so it will be save every time even if the data have not been changed. So we will consume CPU, database write and it could have an impact on application display time. What if you also use Elasticsearch and index your document based on entity life cycle ?
From this analysis, we decided to have our own library to encrypt and decrypt data when needed and not every time we hydrate an entity. We also wanted to have a way to rotate key encryption in case it will be compromised.
We never have in mind to create our own encryption algo, it's a very difficult job. It's a full time job, and other people have better skills than us to do that. So we used the excellent library paragonie/halite.
The first thing to do is to generate key pair. For simplicity, I will store these keys in file on the application server. You can easily use others storage including secured storage like Vault.
Because we want to have rotating keys, it's important to version our key, we add date to key file name for that purpose. And we use a symbolic link on the most recent key, this link will be the default key.
This is Symfony command to generate the keys:
<?php declare(strict_types=1); namespace Core\Infrastructure\Command; use Core\Infrastructure\HaliteEncryption\VersionedEncryptionKey; use ParagonIE\Halite\Alerts\HaliteAlert; use ParagonIE\Halite\KeyFactory; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Filesystem; class EncryptionKeyGeneratorCommand extends Command { private string $encryptionKeyDir; private Filesystem $filesystem; public function __construct(string $encryptionKeyDir, Filesystem $filesystem) { parent::__construct(); $this->encryptionKeyDir = $encryptionKeyDir; $this->filesystem = $filesystem; } protected function configure(): void { $this->setName('app:key-encryption:generate') ->setDescription('Generate encryption key use to secure data.'); } protected function execute(InputInterface $input, OutputInterface $output): int { try { $keyFileName = \sprintf('encryption_%s.key', \date('YmdHis')); $encKey = KeyFactory::generateEncryptionKey(); $success = KeyFactory::save($encKey, $this->encryptionKeyDir.$keyFileName); if (false === $success) { $output->write('<error>Error occured during key writing on disk.</error>'); return 1; } $this->filesystem->symlink( $this->encryptionKeyDir.$keyFileName, $this->encryptionKeyDir.VersionedEncryptionKey::LATEST_KEY_FILENAME ); } catch (HaliteAlert $exception) { $output->write('<error>'.$exception->getMessage().'</error>'); return 1; } return 0; } }
We need to retrieve the key in the storage based on its version. We write a class for that, it returns the last key from the default link or the asked versioned key.
<?php declare(strict_types=1); namespace Core\Infrastructure\HaliteEncryption; use Core\Domain\Exception\LatestEncryptionKeyNotExistsException; use ParagonIE\Halite\KeyFactory; use Symfony\Component\Filesystem\Filesystem; class EncryptionKeyProvider { private string $encryptionKeyDir; private Filesystem $filesystem; public function __construct(string $encryptionKeyDir, Filesystem $filesystem) { $this->encryptionKeyDir = $encryptionKeyDir; $this->filesystem = $filesystem; } public function getKey(?int $version = null): VersionedEncryptionKey { if (null === $version) { $keyPath = $this->filesystem->readlink($this->encryptionKeyDir.VersionedEncryptionKey::LATEST_KEY_FILENAME); if (null === $keyPath) { throw new LatestEncryptionKeyNotExistsException(); } $keyName = \basename($keyPath); $version = \trim(\str_ireplace(['encryption_', '.key'], [''], $keyName)); $latest = true; } else { $keyName = \sprintf('encryption_%s.key', $version); } return VersionedEncryptionKey::create( (int) $version, KeyFactory::loadEncryptionKey($this->encryptionKeyDir.$keyName) ); } }
To reduce I/O, we could keep VersionEncryptionKey classes in an array as a in-memory cache.
DTO we return allows us to easily access the key needed by Halite lib and the version of the key.
<?php declare(strict_types=1); namespace Core\Infrastructure\HaliteEncryption; use ParagonIE\Halite\Symmetric\EncryptionKey; class VersionedEncryptionKey { public const LATEST_KEY_FILENAME = 'encryption_latest.key'; private int $version; private EncryptionKey $key; public static function create(int $version, EncryptionKey $key): self { $self = new self(); $self->version = $version; $self->key = $key; return $self; } public function getVersion(): int { return $this->version; } public function getKey(): EncryptionKey { return $this->key; } }
To associate key version and encrypt string, we concatenate them using this pattern: ||key-version||encrypt_string . This pattern allows us to know if a string in encrypted or not.
We wanted a very simple interface for our encryption lib to avoid using directly Halite lib in our code.
<?php declare(strict_types=1); namespace Core\Application\Encryption; interface EncryptionInterface { public function encrypt(string $text): string; public function decrypt(string $text): string; public function isSupportedEncryptedString(string $text): bool; }
We use a simple regex to know if a string is encrypted.
public function isSupportedEncryptedString(string $text, &$match = null): bool { return 0 < \preg_match('#^(\|\|[0-9]{14}\|\|).*$#', $text, $match); }
Encrypt a string is also simple, we always use the latest key.
public function encrypt(string $text): string { $key = $this->keyProvider->getKey(); $encrypt = Crypto::encrypt(new HiddenString($text), $key->getKey()); return \sprintf('||%d||%s', $key->getVersion(), $encrypt); }
Decrypt a string is easy but first we need to extract key version from the encrypted string to retrieve the appropriate key.
public function decrypt(string $text): string { if (false === $this->isSupportedEncryptedString($text, $match)) { throw new \InvalidArgumentException( 'String should be prefixed by encryption version number like this ||[0-9]{14}||.*' ); } $version = \trim(\str_replace('|', null, $match[1])); $text = \trim(\str_replace($match[1], null, $text)); $key = $this->keyProvider->getKey((int) $version); $hiddenString = Crypto::decrypt($text, $key->getKey()); return $hiddenString->getString(); }
Now we have everything we need to encrypt and decrypt string in our project.
We only have to use Dependency Injection to inject implementation of our interface where we need in our code.
Of course we have some legacy on our project and so we don't always master when some entities are persisted. We are working on that legacy part, meanwhile we used Doctrine ORM life cycle events to decrypt the data.
For that purpose we use this interface on our entities.
<?php declare(strict_types=1); namespace Core\Domain\Model; use Core\Application\Encryption\EncryptionInterface; interface EncryptDataInterface { public function secureContent(EncryptionInterface $encryption): void; }
Using prePersist and preUpdate events, we can encrypt data before Doctrine send them to the database.
<?php declare(strict_types=1); namespace Core\Infrastructure\Event\Doctrine; use Core\Application\Encryption\EncryptionInterface; use Core\Domain\Model\EncryptDataInterface; use Doctrine\Common\EventSubscriber; use Doctrine\ORM\Event\LifecycleEventArgs; use Doctrine\ORM\Events; class EncryptDataSubscriber implements EventSubscriber { private EncryptionInterface $encryption; public function __construct(EncryptionInterface $encryption) { $this->encryption = $encryption; } public function getSubscribedEvents(): array { return [ Events::prePersist, Events::preUpdate, ]; } public function prePersist(LifecycleEventArgs $event): void { $model = $event->getObject(); if ($model instanceof EncryptDataInterface) { $this->secureContent($model); } } public function preUpdate(LifecycleEventArgs $event): void { $model = $event->getObject(); if ($model instanceof EncryptDataInterface) { $this->secureContent($model); } } private function secureContent(EncryptDataInterface $model): void { $model->secureContent($this->encryption); } }
To avoid encrypt an already encrypted string and don't encrypt every time we save the data, we use this code in our entities.
<?php declare(strict_types=1); namespace Bank\Domain\Model; use Core\Application\Encryption\EncryptionInterface; use Core\Domain\Model\EncryptDataInterface; class Iban implements EncryptDataInterface { private ?string $bic; private ?string $unencryptedBic; private ?string $iban; private ?string $unencryptedIban; public function setBic(string $bic): void { $this->unencryptedBic = $bic; $this->bic = null; } public function setIban(string $iban): void { $this->unencryptedIban = $iban; $this->iban = null; } public function secureContent(EncryptionInterface $encryption): void { if (null === $this->bic && null !== $this->unencryptedBic) { $this->bic = $encryption->encrypt($this->unencryptedBic); } if (null === $this->iban && null !== $this->unencryptedIban) { $this->iban = $encryption->encrypt($this->unencryptedIban); } } }
We found another use case. We use a managed Elasticsearch somewhere in the cloud. To reduce database read, we store more data in Elasticsearch documents. These data are not use for search or aggregate and these fields are sensitive data. So we decrypt them before indexing the document in Elasticsearch and we decrypt them to use in our API.
This code is not perfect, we use it for more than a year now without trouble. We're thinking of using Vault to store our key, so we will just have to change the KeyProvider class. We're also thinking of using different key by customers.
Add a comment