Home PHP Chiffrer ses données avec Doctrine, tout en gardant la possibilité de les chercher

Chiffrer ses données avec Doctrine, tout en gardant la possibilité de les chercher

  Hamza Bendhiba 6 min 6 mai 2026

Lors du Symfony Live Paris 2026, Jérôme Tamarelle (@GromNaN) a donné une conférence très concrète sur un problème que tout le monde finit par rencontrer un jour : comment chiffrer les données personnelles stockées en base, sans perdre la capacité de faire des recherches dessus. Le sujet paraît simple sur le papier, il devient vite épineux dès qu’on essaie d’aller au-delà du WHERE email = ?. 

Voici une synthèse des idées clés, transposées au contexte le plus courant côté Symfony : Doctrine ORM avec MySQL ou PostgreSQL. 

Pourquoi chiffrer en base ? 

Le RGPD parle de données à caractère personnel (PII), et la liste est plus large qu’on ne le croit : nom, prénom, email, adresse IP, numéro de carte, géolocalisation, et même des caractéristiques combinées du type « développeur PHP qui habite à Rouen ». Le chiffrement en transit (HTTPS) et au repos (chiffrement disque) ne suffisent pas : si un attaquant met la main sur un dump SQL, il a tout en clair. Le chiffrement au niveau applicatif, champ par champ, ajoute une couche supplémentaire qui rend le dump inutilisable sans la clé. 

Les deux modes de chiffrement à connaître 

Sur AES-256-GCM, qui est la référence aujourd’hui, on a deux façons de produire le ciphertext : 

Chiffrement aléatoire : on tire un IV (Initialization Vector) aléatoire à chaque opération. 

function encryptRandom(string $plaintext, string $dek): string
{
    $iv = random_bytes(16);
    $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, $iv);
    return $iv . $ciphertext;
}

Le résultat change à chaque appel pour la même valeur d’entrée. C’est la version la plus sûre, mais elle empêche toute recherche par égalité directe. 

Chiffrement déterministe : l’IV est dérivé de la donnée elle-même via un HMAC. 

function encryptDeterministic(string $plaintext, string $dek): string
{
    $hmac = hash_hmac('sha512', $plaintext, $dek);
    $iv = substr($hmac, 0, 16);
    $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, $iv);
    return $iv . $ciphertext;
}

Le ciphertext devient stable : la même valeur en clair donne toujours le même résultat chiffré. On peut donc indexer la colonne et faire des WHERE. 

⚠️ Point crucial souligné dans la conf : jamais de hash sans clé secrète. Si on dérive l’IV d’un simple hash('sha512', $email), n’importe quel attaquant qui récupère la base peut tester hash('sha512', '[email protected]') et identifier la victime sans même déchiffrer. L’utilisation de hash_hmac avec une clé secrète bloque cette attaque. 

L’architecture des clés : DEK + Master Key + KMS 

Plutôt que d’avoir une seule clé qui chiffre tout, on adopte un modèle à plusieurs niveaux : 

  • La donnée est chiffrée par une DEK (Data Encryption Key) 
  • La DEK est chiffrée par une Master Key 
  • La Master Key est gérée par un KMS (AWS KMS, Azure Key Vault, Google Cloud KMS, HashiCorp Vault, OVHcloud KMS, Thales CipherTrust…) 

L’intérêt : on peut faire tourner la Master Key régulièrement (tous les 6 à 24 mois) sans avoir à rechiffrer toutes les données, puisque seules les DEK sont concernées. Stocker la clé dans un fichier sur disque reste possible mais déconseillé en production. 

Implémentation avec Doctrine ORM : créer un Type DBAL 

Doctrine fournit un point d’extension parfait pour ce besoin : les Types DBAL. Un Type intercepte les conversions entre PHP et la base de données, dans les deux sens. 

use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\ParameterType;


final readonly class EncryptedType extends Type
{
    public function __construct(
        private Type $parentType,
        private Encryptor $encryptor,
        private string $dekId,
    ) {}


    public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): mixed
    {
        $plaintext = $this->parentType->convertToDatabaseValue($value, $platform);
        if ($plaintext === null) {
            return null;
        }
        return $this->encryptor->encryptRandom($this->dekId, $plaintext);
    }


    public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed
    {
        if (!$value) {
            return null;
        }
        $plaintext = $this->encryptor->decrypt($this->dekId, $value);
        return $this->parentType->convertToPHPValue($plaintext, $platform);
    }


    public function getBindingType(): ParameterType
    {
        return ParameterType::BINARY;
    }


    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
    {
        return $platform->getBinaryTypeDeclarationSQL($column);
    }
}

À noter que jusqu’à récemment Doctrine déclarait le constructeur de Type comme final, ce qui empêchait ce pattern. La PR #6705 mergée en février 2025 (Doctrine DBAL 4.3) lève cette restriction et permet désormais d’enregistrer une instance via Type::getTypeRegistry()->register(). 

L’enregistrement se fait au démarrage de l’application : 

$type = new EncryptedType(
    Type::getTypeRegistry()->get('string'),
    $container->get(Encryptor::class),
    'my-dek-id',
);
Type::getTypeRegistry()->register('user_name_encrypted', $type);

Et l’entité devient : 

#[ORM\Entity]
class User
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    public ?int $id = null;


    #[ORM\Column(type: 'user_email_encrypted', unique: true)]
    public string $email;


    #[ORM\Column(type: 'user_name_encrypted')]
    public string $name;
}

Et la recherche, alors ? 

C’est là que les choses se compliquent avec PostgreSQL ou MySQL. Le chiffrement aléatoire interdit WHERE email = ?. Plusieurs stratégies existent. 

Stratégie 1 : colonnes de hash dédiées 

On stocke l’email chiffré aléatoirement, et à côté on maintient des colonnes de hash HMAC pour les recherches utiles : 

#[ORM\Entity]
class User
{
    #[ORM\Column(type: Types::BINARY)]
    public string $email;


    #[ORM\Column(type: Types::STRING, length: 64)]
    public string $emailHash;


    #[ORM\Column(type: Types::STRING, length: 64)]
    public string $emailDomainHash;
}

Un Doctrine listener calcule ces hash automatiquement avant chaque persist/update : 

#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final class UserEmailHashListener
{
    public function __construct(private readonly string $hmacKey) {}


    private function applyHashes(User $entity): void
    {
        $normalized = strtolower(trim($entity->email));
        $entity->emailHash = hash_hmac('sha256', $normalized, $this->hmacKey);


        $domain = explode('@', $normalized, 2)[1] ?? '';
        $entity->emailDomainHash = hash_hmac('sha256', $domain, $this->hmacKey);
    }
}

Et la recherche dans le repository :

public function findByEmailDomain(string $domain): array
{
    $domainHash = hash_hmac('sha256', strtolower(trim($domain)), $this->hmacKey);
    return $this->createQueryBuilder('u')
        ->andWhere('u.emailDomainHash = :hash')
        ->setParameter('hash', $domainHash)
        ->getQuery()
        ->getResult();
}  

Stratégie 2 : jetons de recherche 

Pour des recherches plus riches (préfixe, sous-chaîne…), on stocke un tableau de hash correspondant à des fragments calculés en amont : 

#[ORM\Column(type: Types::SIMPLE_ARRAY)]
public array $searchTags;

Avec PostgreSQL, le package martin-georgiev/postgresql-for-doctrine apporte le support natif des arrays, JSONB, ranges et plein d’autres fonctions, ce qui ouvre des possibilités intéressantes pour ce genre de patterns. 

Limitations à garder en tête 

Le chiffrement par champ via Doctrine ORM a quelques angles morts : 

  • Le chiffrement aléatoire produit toujours un changement détecté par l’UnitOfWork à chaque flush, ce qui peut polluer la détection de modifications 
  • Les Types DBAL traitent chaque champ individuellement : impossible de répartir une propriété sur plusieurs colonnes (ex. valeur chiffrée + IV séparés) 
  • Les requêtes DQL/SQL brutes et les résultats hydratés en mode array ne passent pas par le Type, donc pas de déchiffrement automatique 

Le chiffrement seul ne suffit pas 

Une partie souvent oubliée : même avec une base parfaitement chiffrée, les données fuitent par les interfaces. Il faut compléter par une restriction d’accès au strict nécessaire, du click-to-reveal sur les champs sensibles, du rate limiting pour éviter l’exfiltration en masse, et de la surveillance avec des alertes sur les usages anormaux. 

Pour aller plus loin 

Sur Doctrine ORM avec PostgreSQL/MySQL, l’approche par Type DBAL + colonnes de hash est solide et déployable progressivement sur une application existante, en chiffrant les champs un par un. Pour des besoins de recherche plus avancés (range, prefix, suffix, substring), Jérôme évoquait le support natif côté Doctrine MongoDB ODM via l’attribut #[Encrypt], qui s’appuie sur Queryable Encryption de MongoDB. Une piste à creuser si la stack le permet. 

Les slides complets de la conférence sont disponibles sur jerome.tamarelle.net. 

Lire les articles similaires

Laisser un commentaire

Social Share Buttons and Icons powered by Ultimatelysocial