Chiffrer des colonnes avec Symfony 8.2 : ce que #[Encrypted] protège, et ce qu'il ne protège pas

Arnaud Pointet · · 11 min de lecture

Read this article in English

Cet article part d'un exercice, à faire dans le navigateur : Chiffrer les colonnes sensibles d'une entité →

Symfony 8.2 apporte un nouveau composant, KeyManagement, encore expérimental. Son pont Doctrine chiffre une colonne avec un attribut, et une colonne chiffrée peut même rester cherchable grâce à un index aveugle.

Il y a peu de code à écrire. Mais chiffrer une colonne n'est pas une case à cocher : il faut savoir contre quoi on se protège, accepter ce que la base ne saura plus faire, et prévoir le jour où l'on chiffre une colonne qui contient déjà des données. Cet article fait le tour de ces questions, sur l'exemple d'une table de patients.

Deux attributs

Chiffrer le téléphone et la date de naissance d'un patient tient en deux lignes, une par colonne :

use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted;

#[ORM\Column(nullable: true)]
#[Encrypted(key: 'patient.contact')]
private ?string $telephone = null;

#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Encrypted(key: 'patient.identite')]
private ?\DateTimeImmutable $dateNaissance = null;

Doctrine écrit dans la base une valeur chiffrée, et la déchiffre en relisant l'entité. La propriété garde son type PHP : le type Doctrine de la colonne convertit d'abord la valeur (une date devient du texte), puis le résultat est chiffré. Il n'y a ni type Doctrine à écrire ni écouteur à déclarer : en compilant le conteneur, Symfony trouve les propriétés marquées et déclare les types qu'il leur faut.

Le chiffrement est aléatoire : la même valeur, chiffrée deux fois, donne deux résultats différents. C'est voulu, sinon il suffirait de comparer les colonnes pour savoir quels patients partagent un numéro. En contrepartie, un WHERE email = ? ne trouve plus rien. Pour retrouver un patient par son adresse chiffrée, on ajoute un index aveugle :

#[ORM\Column]
#[Encrypted(key: 'patient.contact')]
private string $email;

#[ORM\Column(length: 64)]
#[BlindIndexed('email', 'patient-email')]
private string $emailIndex = '';

La colonne emailIndex contient une empreinte à clé de l'adresse : un HMAC-SHA256, calculé sous une sous-clé propre à l'index, dérivée de sa clé et de son nom, patient-email. La même adresse donne toujours la même empreinte, mais il faut la clé pour la calculer, et deux index ne donnent jamais la même empreinte pour une même valeur. Un écouteur la recalcule à chaque flush() qui insère ou modifie l'entité, et la recherche compare des empreintes :

$this->findOneBy(['emailIndex' => $this->index->of($email)]);

Contre quoi cela protège

Avant de chiffrer quoi que ce soit, il faut se demander qui l'on veut empêcher de lire. Le chiffrement de colonnes protège contre ceux qui lisent la base sans passer par l'application :

  • une sauvegarde qui traîne sur un stockage mal protégé ;
  • une copie de la base de production sur un poste de développement, ou dans un environnement de recette ;
  • une injection SQL qui extrait la table : elle obtient des octets chiffrés, pas des numéros de téléphone ;
  • un accès en lecture à la base donné pour du support ou des statistiques.

Il ne protège pas contre l'application elle-même. Elle a accès aux clés, puisqu'elle déchiffre chaque fois qu'elle charge une entité, et les garde en mémoire le temps d'une requête. Quelqu'un qui prend la main sur le serveur de l'application, ou qui lui fait exécuter du code, lit les données en clair comme elle. Et une fois déchiffrée, une valeur est une chaîne comme les autres : elle part dans les journaux si on journalise l'entité, dans Sentry avec la trace d'une exception, dans un cache, dans un export CSV ou dans un moteur de recherche. Le chiffrement s'arrête à la base, la vigilance doit continuer au-delà.

Reste la question de la clé. Le composant pratique le chiffrement d'enveloppe : chaque valeur est chiffrée par une clé de données, elle-même chiffrée par une clé maîtresse qui reste dans un KMS (AWS KMS, Google Cloud KMS, Vault…). Sans configuration particulière, chaque valeur emporte sa propre clé de données, chiffrée, et chaque lecture comme chaque écriture interroge le KMS. Avec un magasin de clés (key_management.store), les clés de données sont rangées, chiffrées, dans une table de la base : toutes les valeurs d'une portée (patient.contact) partagent une clé, et le KMS n'est interrogé qu'une fois par clé et par processus. Sans la clé maîtresse, ces clés rangées en base ne servent à rien.

C'est donc l'emplacement de la clé maîtresse qui décide de toute la protection. Si la clé maîtresse est écrite dans le .env, sur le serveur qui fait aussi les sauvegardes de la base, quelqu'un qui récupère le serveur entier récupère les deux. Un KMS local avec sa clé dans le .env convient pour développer ; en production, la clé maîtresse vit ailleurs que la base, et idéalement ne quitte jamais le KMS.

Ce que la base ne sait plus faire

Une colonne chiffrée est une colonne binaire opaque. La base ne peut plus rien en faire, et cela va plus loin que le WHERE :

  • Ni tri ni recherche partielle. Pas d'ORDER BY, pas de LIKE, pas de comparaison de dates. C'est pourquoi, dans notre table de patients, le nom reste en clair : la liste est triée par nom, en SQL. On ne chiffre que ce qui doit l'être, et une date de naissance chiffrée ne permet plus de chercher « les patients nés avant 1950 ».
  • Pas de contrainte d'unicité. Deux adresses identiques donnent deux valeurs chiffrées différentes, donc un index UNIQUE sur la colonne chiffrée ne refuse jamais rien. C'est sur la colonne de l'index aveugle qu'il faut le poser : deux adresses identiques y ont la même empreinte, au sens de la projection (voir plus bas).
  • Pas de longueur. Le type déclare une colonne binaire sans limite (LONGBLOB, BYTEA…), quel que soit le length du mapping. Ce length décrit la valeur en clair, et le résultat chiffré est plus long.
  • Une valeur n'est pas liée à sa ligne. Le chiffrement ne sait pas dans quelle ligne il écrit. Quelqu'un qui peut écrire dans la base peut donc copier le téléphone chiffré d'un patient dans la même colonne d'un autre patient, et il se déchiffrera sans erreur. Si ce risque compte pour vous, il faut lier la valeur à sa ligne vous-même, par exemple en y incluant l'identifiant de la ligne et en le vérifiant après lecture.

Ce que l'index aveugle révèle

L'index aveugle ne révèle pas les adresses, mais il révèle une chose que le chiffrement cachait : quelles lignes partagent la même valeur, et combien de fois chaque valeur revient. Sur une adresse, ce n'est presque rien, car presque toutes les adresses sont différentes. Sur une colonne qui prend peu de valeurs, c'est beaucoup plus grave : un index aveugle sur le pays de naissance ou le groupe sanguin montre immédiatement la répartition, et il suffit de connaître la valeur d'une seule ligne pour connaître celle de toutes les lignes qui portent la même empreinte. On n'indexe donc que des valeurs variées, cherchées à l'égalité : une adresse, un numéro de dossier, un numéro de sécurité sociale.

Deux autres points sont propres à l'index aveugle.

Sa clé ne change jamais. Avec un magasin, la clé de données d'une colonne chiffrée est renouvelée toute seule : passé trente jours, la prochaine écriture en crée une nouvelle, et les anciennes restent lisibles. Ce n'est pas de l'hygiène : chaque valeur est chiffrée avec un nonce aléatoire de 96 bits, et la norme (NIST SP 800-38D) limite une clé à 2^32 chiffrements dans ce cas. La clé de l'index, elle, ne doit jamais être renouvelée : toutes les empreintes déjà écrites ont été calculées avec elle, et une nouvelle clé ne retrouverait plus personne. Changer cette clé, c'est recalculer toute la colonne.

La projection décide de ce qui est égal. Pour une adresse, on choisit la projection Email, qui normalise le domaine (sa casse, et la forme ASCII d'un domaine internationalisé) et ignore les espaces autour de l'adresse : ada@Example.ORG et ada@example.org donnent la même empreinte. Elle ne touche pas à la partie avant @, que la norme déclare sensible à la casse. Si votre application considère que Ada@example.org et ada@example.org sont la même personne, normalisez l'adresse avant de l'enregistrer : l'index compare ce qu'on lui donne.

Chiffrer une colonne qui existe déjà

Sur une base vide, il suffit d'ajouter l'attribut. Dans une vraie application, la colonne du téléphone contient déjà des milliers de numéros en clair, et c'est là que l'ajout devient délicat.

Ajouter #[Encrypted] change le type de la colonne : la migration générée par Doctrine la transforme en colonne binaire, mais ne chiffre rien. Sur PostgreSQL, elle échoue même telle quelle : il faut y ajouter USING telephone::bytea (et USING date_naissance::text::bytea pour une date) pour que la base accepte la conversion. Les numéros restent en clair, et à la première lecture, Doctrine refuse ce qu'il trouve :

Doctrine\DBAL\Types\Exception\ValueNotConvertible:
Stored value is not a valid KeyManagement envelope.

Chaque ligne non chiffrée fait donc échouer le chargement de son entité. Et on ne peut pas chiffrer en passant par l'ORM : il ne peut pas charger ces entités, et même s'il le pouvait, il verrait des valeurs inchangées et n'écrirait rien. Il faut passer par DBAL, en demandant à Doctrine le nom du type que Symfony a déclaré pour la colonne :

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

$metadata = $entityManager->getClassMetadata(Patient::class);
$telephone = Type::getType($metadata->getTypeOfField('telephone'));
$connexion = $entityManager->getConnection();
$plateforme = $connexion->getDatabasePlatform();

foreach ($connexion->iterateAssociative('SELECT id, telephone FROM patient ORDER BY id') as $ligne) {
    $connexion->update(
        'patient',
        ['telephone' => $telephone->convertToDatabaseValue($ligne['telephone'], $plateforme)],
        ['id' => $ligne['id']],
        ['telephone' => ParameterType::BINARY],
    );
}

getTypeOfField() renvoie le nom du type que Symfony a déclaré pour cette colonne (ici encrypted_string@patient.contact), et ce type chiffre exactement comme le fera l'application. Pour une date, on reconstruit d'abord le DateTimeImmutable à partir de la chaîne stockée, par exemple avec Type::getType('date_immutable')->convertToPHPValue(), puis on le passe à convertToDatabaseValue(), comme le ferait l'ORM.

Tel quel, ce script n'est pas idempotent : relancé, il chiffrerait une seconde fois les lignes déjà chiffrées. Pour qu'il le devienne, il suffit de sauter les lignes que Envelope::fromBytes() accepte : une valeur en clair n'est jamais une enveloppe valide, et la méthode lève alors une exception. Enfin, entre la migration du schéma et la fin du script, toutes les lignes qui ne sont pas encore chiffrées sont illisibles. Il y a deux façons de s'y prendre.

  • Une courte maintenance. On enchaîne la migration du schéma et le script, application arrêtée. C'est le plus simple. Avec un magasin de clés, où le KMS n'est interrogé qu'une fois par portée, quelques centaines de milliers de lignes se chiffrent en quelques minutes. Sans magasin, chaque ligne coûte un appel au KMS, lent et facturé.
  • Une colonne parallèle. On ajoute une colonne chiffrée à côté de l'ancienne, l'application écrit dans les deux et lit l'ancienne. Un script remplit la nouvelle par lots, en ne traitant que les lignes où elle est vide. On bascule ensuite la lecture, puis on supprime l'ancienne colonne. C'est plus long, mais rien ne s'arrête.

Dans les deux cas, n'oubliez pas les données qui ne sont pas dans la table : les sauvegardes faites avant la migration contiennent toujours les numéros en clair, et le resteront tant qu'elles seront conservées.

L'index aveugle pose le même problème. L'écouteur ne calcule une empreinte que pour les entités qu'un flush() insère ou modifie : les lignes qui existaient avant l'ajout de la colonne gardent une empreinte vide tant qu'elles ne sont pas modifiées, et aucune recherche ne les trouve d'ici là. Il faut donc les remplir une fois, par le même genre de script : relire l'adresse avec convertToPHPValue() du type chiffré, puis écrire $index->of() dans la colonne. Le même piège attend tout UPDATE en masse, en SQL comme en DQL : il change l'adresse sans changer l'empreinte, et la recherche renvoie alors une ligne qui n'a plus cette adresse.

Ce que j'en retiens

#[Encrypted] règle la partie la plus technique : un chiffrement correct, des clés de données renouvelées toutes seules, une clé maîtresse qu'on peut changer sans réécrire les lignes, et même déplacer vers un autre fournisseur quand les clés de données sont rangées dans un magasin. Écrire cela à la main, avec un type Doctrine maison, était une source d'erreurs.

Mais le chiffrement de colonnes reste un choix de conception. Il protège la base, pas l'application ; il retire à la base le tri, la recherche et l'unicité ; et l'index aveugle, qui rend une colonne cherchable, révèle ce que le chiffrement cachait. Avant de poser l'attribut, il vaut la peine d'écrire noir sur blanc contre qui on se protège, où vit la clé maîtresse, et comment on chiffrera les données qui sont déjà là.

Pour essayer sans rien installer, deux exercices de Pratique tournent dans le navigateur : colonnes chiffrées et index aveugle.

← Tous les articles