Sécuriser ses données via le chiffrement en PHP

Peu importe les données que notre application utilise, il y en a toujours qui sont sensibles du point de vue l’utilisateur: identité de l’utilisateur, donnée bancaire ou patrimonial, historique d’achat ou du point de vue de l’entreprise : rapport de faille de sécurité, liste de prospect.

Si ces données fuitent, les problèmes commencent, ne pouvant garantir qu’elle ne seront jamais volées, le mieux restent de les chiffrer pour les rendre inexploitables.

Il existe des solutions et bundle pour Symfony et Doctrine qui permettent de chiffrer/déchiffrer à la volée des données qui sont stockés en base de donnée comme le bundle ambta/doctrine-encrypt-bundle que l’on avait sur notre projet.
Ces solutions se basent sur les événements du cycle de vie d'une entité dans Doctrine ORM et cela pose quelques problèmes.
Lors du chargement (au moment de l'hydratation) d'une entité on consomme de la ressource et donc du temps pour déchiffrer les champs même si l'on en a pas besoin. Cela rend très compliqué l'utilisation de Doctrine DBAL car les données ne sont pas déchiffrées et le service pour les déchiffrer n'est pas des plus simple à accéder.
Autre problème, les champs chiffrés ayant été déchiffré, il ne sont donc plus synchro dans l'UOW de Doctrine et donc l'entité sera sauvegardé en base à chaque flush même si rien n'a changé. Cela implique de nouveau de la consommation de ressource coté PHP mais aussi côté base de donnée avec beaucoup d'écritures inutiles qui peuvent ralentir tous le site. Pour peu que vous indexez les entités dans Elasticsearch en écoutant sur les événements de Doctrine et c'est le drame.

De ce constat, nous avons décidé de faire notre propre implémentation pour chiffrer et déchiffrer les données au moment opportun dans notre application. Avec comme volonté de pouvoir faire de la rotation de clé pour le chiffrement au cas où elle serait compromise.
A aucun moment, il n'a été question de concevoir notre algo de chiffrement ou d'utiliser une API bas niveau en jouant sur les multiples paramètres. Il y a des gens dont c'est le métier, qui ont le temps d'étudier tout ça. Nous sommes donc parti de l'excellente librairie paragonie/halite.

La première chose à faire et de générer une paire de clé. Pour simplifier les choses dans l'article je vais utiliser des clés qui sont en fichier sur le serveur qui fait tourner notre application. Il est possible d'utiliser d'autre système de stockage et plus sécurisé comme Vault par exemple.
Étant donnée que nous souhaitons avoir de la rotation de clé, il est important de les versionner, on a choisi pour ça de suffixé le nom par la date de création. Et on créé un lien symbolique qui pointe sur la clé la plus récente, qui sera notre clé par défaut.

Voici la commande Symfony pour créer des clés:


<?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;
    }
}


Pour chiffrer ou déchiffrer on a besoin de retrouver la clé sur le disque en fonction de sa version. Nous avons écrit une classe pour ça, qui soit retourne la dernière clé à partir du lien symbolique soit la clé à la version demandée.

<?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)
        );
    }
}
 


Pour limiter les I/O sur le disque, on pourrait ajouter du cache en mémoire en stockant les VersionedEncryptionKey générées dans un tableau.
Le DTO que l'on retourne nous permet d'accéder facilement à la clé qu'attend la libraire Halite et à la version de notre clé.

<?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;
    }
}
 


Pour associer la version de la clé avec le texte chiffré, on a décidé de concaténer les deux sous la forme: ||version_clé||texte_chiffré. Ce motif nous permet de tester si un texte est chiffré ou non, ce que l'on va utiliser plus tard comme contrôle.

On a souhaité une interface simple pour notre composant de déchiffrement qui va nous permettre de nous abstraire de la librairie Halite.


<?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;
}


Pour savoir si un texte est chiffré une simple regex avec le motif que l'on a définis suffit:


public function isSupportedEncryptedString(string $text, &$match = null): bool
{
    return 0 < \preg_match('#^(\|\|[0-9]{14}\|\|).*$#', $text, $match);
}


Le chiffrement est très simple, on passe un texte et on utilise la dernière version de la clé pour le chiffrer, on y ajoute la version de la clé et on retourne le tout:

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);
}


Pour déchiffrer un texte on commence par extraire le numéro de version de la clé pour la récupérer puis on déchiffre:

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();
}


On a maintenant tout ce qu'il faut pour chiffrer et déchiffrer du texte dans notre projet.

Il suffit d'injecter l'implémentation de l'interface EncryptionInterface là où c'est nécessaire.

Notre projet ayant du legacy, il y a des endroits dans le code où l'on ne maitrise pas vraiment le moment de la sauvegarde des entités. En attendant d'avoir effacé notre dette technique, nous nous sommes appuyé sur les événements de Doctrine ORM pour chiffrer nos données.
Pour cela nous avons créé une interface que certaines de nos entités implémentent:

<?php
 
declare(strict_types=1);
 
namespace Core\Domain\Model;
 
use Core\Application\Encryption\EncryptionInterface;
 
interface EncryptDataInterface
{
    public function secureContent(EncryptionInterface $encryption): void;
}
 


En utilisant les events prePersist et preUpdate on peut donc chiffrer les données dans les entités avant l'envoi à la base de donnée.


<?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);
    }
}
 


Pour ne pas chiffrer un texte déjà chiffré ou retomber dans le piège du chiffrement systématique, l'astuce se trouve dans les entités qui implémente EncryptDataInterface.

<?php
 
declare(strict_types=1);
 
namespace Bank\Domain\Model;
 
use Core\Application\Encryption\EncryptionInterface;
 
class Iban implements IbanBankInterface
{
    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);
        }
    }
}
 


Nous avons également trouvé un autre cas d'utilisation. Par facilité nous utilisons un Elasticsearch managé en dehors de notre infra. Afin de limiter les accès à la base de donnée, certains champs sont présent dans ES uniquement pour le résultat envoyé par l'API et non pour la recherche et comme ces champs sont des données sensibles, il nous a été facile de les chiffrer comme on a fait pour Doctrine.

Ce code n'est pas parfait, mais nous l'utilisons depuis plus d'un an et on a aucun soucis avec. Nous envisageons de bouger les clés dans Vault ce qui va demander un peu d'adaptation mais ce n'est pas grand chose, il suffit de modifier le KeyProvider. On se prépare également à peut être devoir gérer des clés par client, là aussi tout se passe dans le KeyProvider.


L'ajout de commentaire est temporairement désactivé à cause du spam.

Recherche

Détails de l'article