Petit exemple pratique pour l'utilisation de Symfony et le chiffrement des informations personnelles des utilisateurs.
Cet article est la suite du premier article du blog : Le respect de la vie privée.
On démarre avec une application classique :
composer create-project symfony/skeleton:"7.2.x-dev" my_project_directory  
cd my_project_directory  
composer require webapp
On paramètre ensuite le .env de notre application pour l'accès à la base de données:
DATABASE_URL="mysql://userDB:passwordDB@127.0.0.1:3306/blog_encryption?serverVersion=8.0.32&charset=utf8mb
Puis on crée notre entité Customer (php bin/console make:entity) avec les infos ci-dessous :
- 
id (string)
 - 
firstname (string)
 - 
email (string)
 - 
address (string)
 - 
city (string)
 
On crée alors une dizaine de Customer.
Pour la démonstration j'utilise le bundle de DataFixtures et Faker-PHP pour générer des users à la volée (on simule ici une soumission de formulaire).
// src/DataFixtures/AppFixtures.php     
namespace App\DataFixtures;    
use App\Entity\Customer;  
use Doctrine\Bundle\FixturesBundle\Fixture;  
use Doctrine\Persistence\ObjectManager;  
use Faker\Factory;  
use Faker\Generator;
class AppFixtures extends Fixture  
{      
    protected Generator $faker;        
    public function __construct()      
    {          
    $this->faker=Factory::create();      
    }       
    public function load(ObjectManager$manager):void      
    {          
        for ($i=0; $i<10; $i++) {                
            $customer=newCustomer();         
            $customer->setFirstname($this->faker->firstname());              
            $customer->setEmail($this->faker->safeEmail());              
            $customer->setAddress($this->faker->address());              
            $customer->setCity($this->faker->city());
            $manager->persist($customer);            
        }          
        $manager->flush(); 
    }
}   
A ce stade nous avons bien une insertion de users en clair dans notre DB.
Les users en clair
Le truc c'est qu'au niveau du RGPD, c'est pas très propre ça. On peut aisément comprendre que des données en clair comme celle-ci peuvent nuire à la personne en cas de fuite de données.
C'est pourquoi on va mettre en place un chiffrement des données. En fonction de l'application et de son usage, on ira plus ou moins loin dans le chiffrement (notamment sa méthode) mais au moins à minima, un chiffrement basique histoire de ne pas voir les données en clair.
Je te laisse aller sur l'excellent blog chiffrer.info qui explique très bien la cryptographie. Juste une petite piqûre de rappel...
on dit chiffrer, pas crypter ! Chiffrer.info
Pour la suite on partira sur un chiffrement symétrique (c'est à dire qu'on aura utilisé la même clé pour chiffrer et déchiffrer).
Rentrons dans le vif du sujet avec la première étape, notre service de chiffrement.
// src/Services/Encryption.php     
namespace App\Services;    
use Exception;  
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;    
class Encryption  
{      
    private string $encryptionKey;        
    public function __construct(ParameterBagInterface$parameters)      
    {          
        $this->encryptionKey=$parameters->get('encryptionKey');      
    }
    public function encrypt(string$data):string      
    {          
        $iv=random_bytes(openssl_cipher_iv_length('aes-256-cbc'));          
        returnbase64_encode($iv.openssl_encrypt($data,'aes-256-cbc',$this->encryptionKey,0,$iv));      
    } 
    public function decrypt(string$data):string      
    {          
        $decodedData=base64_decode($data);            
        $ivLength=openssl_cipher_iv_length('aes-256-cbc');          
        $iv=substr($decodedData,0,$ivLength);          
        $encryptedData=substr($decodedData,$ivLength);            
        returnopenssl_decrypt($encryptedData,'aes-256-cbc',$this->encryptionKey,0,$iv);      
    }  
}   
Explications :
On stock la clé de chiffrement dans notre .env que l'on vient récupérer avec notre ParameterBagInterface.

Cette clé est secrète, il y a plusieurs façons de générer une clé de chiffrement. Pour ici on se contentera de la fonction random_bytes(32) de PHP. Tu peux la dd() pour la récupérer et la glisser dans ton .env.
Attention, cette clé est secrète, c'est la clé de voute (t'as vu le jeu de mots ? 😔) de ton chiffrement.
Ensuite on crée deux fonctions, une qui chiffre (encrypt), une qui déchiffre (decrypt).
Les deux fonctions utilisent globalement les mêmes informations :
- 
La data à utiliser ($data) qui sera soit la donnée en clair (chiffrement) soit la donnée chiffrée (déchiffrement)
 - 
La clé ($this->encryptionKey)
 - 
Le mode de chiffrement (AES-256-CBC)
 - 
l'IV
 
Petit apparté sur l'IV (Vecteur d'Initialisation). C'est globalement la même utilisation qu'un salt pour un password. L'IV est donc concaténée à la data chiffrée afin de pouvoir la récupérer lors du déchiffrement.
Bon, c’est bien beau tout ça mais finalement … Comment l'utiliser ?
On va faire un écouteur d'évènement qui va chiffrer lors d'un pre-update et un pre-persist et qui déchiffrera lors d'un post-load.
Pourquoi déchiffrer à chaque fois qu’on load de la data et ne chiffrer qu’une fois qu’il y a une action de modification / création ? Pour des questions de performances.
Les processus de chiffrement sont des processus qui demandent pas mal de ressources à un système. Le déchiffrement beaucoup moins. Par conséquent, moins on chiffre, mieux c’est pour les performances.
On va donc modifier un peu notre entité Customer.
On va garder la table SQL intacte mais on lui ajoutera des propriétés qui ne seront pas mappées en DB => Les données déchiffrées ! (Sinon ça n'a pas d'intérêt 😜)
//src/Entity/Customer.php    
namespace App\Entity;    
use App\Repository\CustomerRepository;  
use Doctrine\ORM\Mapping as ORM;    
#[ORM\Entity(repositoryClass: CustomerRepository::class)]  
class Customer  
{      
    #[ORM\Id]      
    #[ORM\GeneratedValue]      
    #[ORM\Column]      
    private ?int $id = null;  
    #[ORM\Column(length: 255)]      
    private ?string $firstname = null;
    private string $decryptedFirstname;
    #[ORM\Column(length: 255)]      
    private ?string $email = null;
    private string $decryptedEmail;
    #[ORM\Column(length: 255)]      
    private ?string $address = null;
    private string $decryptedAddress;
    #[ORM\Column(length: 255)]      
    private ?string $city = null;
            
    private string $decryptedCity;   
Du coup on retourne dans notre fixture pour modifier un peu la création des customers :
// src/DataFixtures/AppFixtures.php    
public function load(ObjectManager$manager): void  
{     
    for ($i=0; $i<10; $i++) {          
        $customer=newCustomer();     
           
        $customer->setDecryptedFirstname($this->faker->firstname());        
        $customer->setDecryptedEmail($this->faker->safeEmail());        
        $customer->setDecryptedAddress($this->faker->address());        
        $customer->setDecryptedCity($this->faker->city());    
        $manager->persist($customer);     
    }  
    $manager->flush();  
}   
Quand le système flush, il va chiffrer la data et l’injecter dans les propriétés qui seront envoyées en DB.
Pour ça, on se sert d’un Event Listener.
// src/EventListener/EncryptionListener.php    
#[AsDoctrineListener(event: Events::prePersist, priority: 100, connection: 'default')]  
#[AsDoctrineListener(event: Events::preUpdate, priority: 100, connection: 'default')]  
class EncryptionListener  
{      
    private Encryption $encryption;        
    public function __construct(Encryption $encryption)      
    {          
        $this->encryption = $encryption;      
    }        
    public function prePersist(PrePersistEventArgs $args): void      
    {          
        $entity = $args->getObject();            
        if ($entity instanceof Customer) {              
            $this->encryptionCustommer($entity);          
        } else {              
            return;          
        }      
    }        
    public function preUpdate(PreUpdateEventArgs $args): void      
    {          
        $entity = $args->getObject();            
        if ($entity instanceof Customer) {              
            $this->encryptionCustommer($entity);          
        } else {              
            return;          
        }      
    }  
    private function encryptionCustommer(Customer $customer)      
    {          
        $customer->setEmail($this->encryption->encrypt($customer->getDecryptedEmail()));          
        $customer->setFirstname($this->encryption->encrypt($customer->getDecryptedFirstname()));          
        $customer->setAddress($this->encryption->encrypt($customer->getDecryptedAddress()));          
        $customer->setCity($this->encryption->encrypt($customer->getDecryptedCity()));      
    }  
}
Comment fonctionne un Event Listener ?
Plutôt simple, à chaque fois que Symfony détectera l'événement sur lequel on souhaite surveiller le déclenchement (ici Events::prePersist et Events::preUpdate) la fonction associée à cette surveillance sera déclenchée. Ici : la fonction de chiffrement.
Comme ça, on a même plus besoin de s’occuper de transformer les données déchiffrées en données chiffrées, l’écouteur le fait pour nous ! C’est pas génial ?!
On refait une insertion :
Les users chiffrés en DB
On dirait qu'on se rapproche de ce que l'on veut !
- 
Côté R.G.P.D ✅
 - 
Côté lecture ❌
 
C'est illisible (bon ok c'est ce qu'on veut aussi) mais c'est impossible de bosser avec sur de la logique métier. On va donc maintenant s'occuper de déchiffrer tout ça directement en surveillant l'event postLoad dans EncryptionListener
On ajoute alors la fonction postLoad :
//src/EventListener/EncryptionListener.php    
#[AsDoctrineListener(event: Events::postLoad, priority: 100, connection: 'default')]  
class EncryptionListener  
{      
    private Encryption $encryption;        
    public function __construct(Encryption $encryption)      
    {          
        $this->encryption = $encryption;      
    }        
    public function postLoad(PostLoadEventArgs $args): void      
    {          
        $entity = $args->getObject();     
               
        if ($entity instanceof Customer) {              
        $entity->setDecryptedEmail($this->decrypt($entity->getEmail()));              
        $entity->setDecryptedFirstname($this->decrypt($entity->getFirstname()));              
        $entity->setDecryptedAddress($this->decrypt($entity->getAddress()));              
        $entity->setDecryptedCity($this->decrypt($entity->getCity()));          
        } else {              
            return;          
        }      
    }  
}   
Maintenant on crée un controller pour récupérer la data :
//src/Controller/CustomerController.php    
class CustomerController extends AbstractController  
{      
    #[Route(path: '/getCustomers', name: 'getCustomers', methods: ['GET'])]      
    public function getCustomers(CustomerRepository $customerRepository)      
    {          
        $customers = $customerRepository->findAll();          
        $data = [];            
        foreach ($customers as $customer) {              
            array_push($data, [
                'firstname' => $customer->getDecryptedFirstname(), 
                'address' => $customer->getDecryptedAddress()
                ]
            );          
        }            
        return $this->json($data);      
    }  
}   

Voilaaaaaa ! Avec ça maintenant, on a une base saine pour bosser sur des données personnelles.
Les améliorations que l'on pourrait faire afin d'améliorer la sécurité du chiffrement serait bien évidement de stocker la clé de chiffrement ailleurs que dans notre application. Le .env c'est bien mais ce n'est pas sécurisé (un attaquant qui accèderait au code source, récupèrerait aussi la clé et par conséquent déchiffrerait les données immédiatement).
L'idée serait de stocker la clé dans un vault, en cloud par exemple (GCP, AWS etc ...) ou bien avec des bundles comme Hashicorp, comme ça on minimise les risques.
Comment on fait pour une application existante ?
On a juste à ajouter les champs $decrypted à l'entité que l'on souhaite protéger, le service de chiffrement et l'event listener. En parallèle on fait une fonction qui va récupérer l'ensemble de l'entité sensible et on chiffre le tout (Si gros volume de data, on peut aussi faire par batch plutôt que d'un seul coup pour éviter d'utiliser trop de ressources serveur).
On pourra aussi faire une fonction de rotation de clés en cas de clé corrompue. Dans ce cas on déchiffre tout avec l'ancienne clé et on rechiffre tout avec la nouvelle clé. De cette façon, même en cas de fuite de clé, on peut agir très rapidement pour la changer. Une faille de sécurité c’est stressant alors tout ce qui est automatisable lors de ces moments là ne peut être qu’un gain de temps pour réagir rapidement.
Tu l’auras compris par contre, en chiffrant les données dans la DB, on perd la capacité de recherche directement en SQL d’une donnée.
Si je cherche le firstname “Austin” en SQL :

Et pourtant c’est bien ma seconde ligne.
L’impossibilité de rechercher des données directement en SQL est un problème et il faudra charger l’ensemble de la table contenant une colonne chiffrée puis déchiffrer pour comparer et retourner la bonne ligne. Sur de grands volumes de données c’est extrêmement coûteux en ressources.
Ce point noir est essentiellement la cause qui mène les acteurs du web à ne pas prendre la peine de chiffrer. Pour le debug d’une base de données c’est l’enfer. Mais ce sont les données de clients, de personnes qui souhaitent nous faire confiance (quand il coche la fameuse case des mentions légales ou une signature d’un contrat). C’est la confiance qu’ils nous accordent pour garder leurs données à l’abri.
Dans l’exemple j’ai chiffré le mail. Mais pour un cas de production, on va aussi le hasher avec un salt comme ça on aura juste à comparer le login soumis hashé par l’utilisateur au moment de sa connexion, avec le hash en DB, sans devoir load l’ensemble de la DB. Un gain de temps et de ressources.
Pour finir sur une note plus optimiste à propos de cette méthode, pour l’exemple j’ai chiffré tous les champs. Mais dans la réalité, on ne chiffre que la donnée personnelle qui permet de reconnaître une personne donc toujours penser avant d’enregistrer une donnée : “ai-je besoin de l’avoir dans mon application” ?
J’espère que cet article vous aura convaincu de l’intérêt du chiffrement et de la nécessité de l’avoir dans notre écosystème Symfony.
