Blog

Envelope Encryption in Symfony 8.2: Schließ den Key weg, nicht nur die Daten

Symfony 8.2 verschlüsselt eine Doctrine-Spalte mit einem einzigen Attribut. Der Trick dahinter heißt Envelope Encryption: Schließ die Daten mit einem Key ab, und diesen Key mit einem zweiten, der deinen Key-Dienst nie verlässt. Hier siehst du, wie das funktioniert, mit einer Spielwiese zum Ausprobieren.

symfony php security 4 Min. Lesezeit

Von David Gorges Mit KI-Unterstützung geschrieben KI hat bei Recherche, Gliederung und ersten Entwürfen geholfen. David Gorges hat jeden Fakt geprüft, den Text überarbeitet und steht hinter jedem Satz.

Geschäftsführer

Ein Attribut, und die Spalte ist verschlüsselt

Hier ist das neue Feature in einem Snippet:

src/Entity/Customer.php
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\Encrypted;

#[ORM\Entity]
class Customer
{
    // ...

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

Das war's. In PHP ist $phone weiter ein ganz normaler String. In der Datenbank stehen unlesbare Bytes. Symfony verschlüsselt den Wert beim Speichern und entschlüsselt ihn beim Laden.

Aber Verschlüsselung ist nur so gut wie der Ort, an dem der Key liegt. Steht der Key in der .env neben dem Datenbank-Passwort, hat jeder, der das eine klaut, ziemlich sicher auch das andere. Genau dieses Problem löst Envelope Encryption.

Die Idee: zwei Keys

Stell dir eine verschlossene Kiste vor:

  • Der Data Key schließt die Kiste ab. Er verschlüsselt deine Telefonnummer.
  • Der Master Key schließt den Data Key ab. Er lebt in einem KMS, einem Key-Dienst wie AWS KMS oder HashiCorp Vault, und verlässt ihn nie.

Die verschlossene Kiste und ihr verschlossener Key liegen also nebeneinander, und nur das KMS kann den Key öffnen. Einen Key mit einem anderen Key abschließen nennt man Wrappen. So sieht das aus, wenn du einen Kunden speicherst:

Eine Telefonnummer speichern
  1. KMS nach App Data Key ×2

    Das KMS erzeugt einen neuen Data Key

    Und gibt ihn zweimal heraus: einmal unverschlüsselt, einmal mit dem Master Key verschlüsselt.

  2. App verschlüsselte Nummer

    Deine App verschlüsselt die Telefonnummer

    Mit dem unverschlüsselten Data Key, direkt in PHP (AES-256-GCM). Die Telefonnummer geht nie zum KMS.

  3. App nach Datenbank beides verschlüsselt

    In der Datenbank landet nur Verschlüsseltes

    Die verschlüsselte Nummer und der verschlüsselte Data Key. Den unverschlüsselten Data Key vergisst die App, er landet nie auf der Platte.

Lesen funktioniert andersherum: Die App schickt den verschlüsselten Data Key zum KMS, bekommt ihn unverschlüsselt zurück und entschlüsselt damit die Nummer.

Probier es selbst aus. Das hier ist echte Verschlüsselung, in deinem Browser:

Echtes AES-256-GCM, in deinem Browser

Diese Spielwiese verschlüsselt wirklich, direkt in deinem Browser. Dafür braucht sie JavaScript und eine sichere Verbindung (https).

Nichts verlässt deinen Browser. Der Master Key ist ein WebCrypto-Key, den nicht mal der Code dieser Seite lesen kann.

Klick zweimal mit derselben Nummer auf „Verschlüsseln & speichern“: völlig andere Bytes. Merk dir das, es wird später wichtig.

Was ein Angreifer bekommt

Jetzt der Teil, für den wir das alles machen.

Der Einbruch
  1. Datenbank nach Angreifer SQL-Dump

    Ein Angreifer kopiert die Datenbank

    Ein Backup am falschen Ort, eine SQL-Injection. Er hat jetzt jede Zeile.

  2. Angreifer nach KMS bitte entschlüsseln?

    Entschlüsseln kann den Data Key nur das KMS

    Im Dump ist alles verschlüsselt, auch die Data Keys.

  3. KMS nach Angreifer abgelehnt

    Das KMS lehnt ab

    Kein Zugriff auf das KMS, keine Data Keys, keine Telefonnummern.

Gut zu wissen

Suchen wird schwieriger. Dieselbe Telefonnummer wird nie zu denselben Bytes (erinnerst du dich an die Spielwiese?), also findet WHERE phone = ? nichts mehr. Für exakte Suchen gibt es in Symfony einen Blind Index: eine zusätzliche Spalte mit einem Fingerabdruck des Werts.

src/Entity/User.php
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\KeyManagement\BlindIndex\Email;
use Symfony\Component\KeyManagement\Bridge\DoctrineOrm\Attribute\BlindIndexed;

#[ORM\Entity]
class User
{
    #[ORM\Column(type: 'encrypted_string')]
    private string $email = '';

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

    // ...
}

// in a repository, look the row up by the digest of the value
$user = $this->findOneBy(['emailIndex' => $this->emailBlindIndex->of($email)]);

Weniger KMS-Aufrufe. Standardmäßig bringt jeder Wert seinen eigenen verschlüsselten Data Key mit, also fragt jedes Speichern und jedes Laden beim KMS an. Mit der Option key_management.store liegen die verschlüsselten Data Keys in einer eigenen Tabelle und werden geteilt. Dann wird das KMS einmal pro Key gefragt statt einmal pro Zeile.

Den Master Key zu wechseln ist billig. Mit dieser Key-Tabelle verschlüsselt ein einziger Befehl alle Data Keys neu, mit einem neuen Master Key. Deine Kundenzeilen bleiben unberührt:

Shell
php bin/console key-management:rewrap-data-keys

Was du jetzt tun kannst

  1. Die Spalten auflisten, die wirklich personenbezogen sind.

    Telefon, Geburtsdatum, Notizen: alles, was in einem Leak wehtut.

  2. In Produktion ein echtes KMS nehmen.

    Lokal reicht sodium://. In Produktion bringt dir ein Master Key in der .env gar nichts.

  3. Jeder Spalte, nach der du suchst, einen Blind Index geben.

    Bevor die erste Query plötzlich nichts mehr findet.

  4. Erst mit einer Kopie der Datenbank ausprobieren.

    Die Symfony-Posts sagen nicht, wie bestehende, noch unverschlüsselte Zeilen verschlüsselt werden.

In einem Satz: In der Datenbank liegen nur verschlüsselte Werte und verschlüsselte Data Keys, und nur das KMS entscheidet, wer sie entschlüsseln darf.

Quellen

6 Quellen
  1. Envelope encryption

    cloud.google.com

Inhalt