Skip to content

Secret

Verschlüsselte Werte in .env-Dateien: entschlüsselt erst zur Ladezeit, nie im Klartext gespeichert.

Einführung

Passwörter und API-Keys in .env-Dateien sind ein Klassiker, und ein Risiko. Selbst wenn die Datei nicht ins VCS gehört: Auf dem Server liegt sie im Klartext. Ein versehentliches Backup, ein falsch konfigurierter Log-Eintrag, ein neugieriger Blick über die Schulter, und die Credentials sind kompromittiert.

jardissupport/secret verschlüsselt sensible Werte direkt in der .env-Datei. Die Entschlüsselung geschieht erst zur Ladezeit, gesteuert durch einen separaten Key. Der Key liegt nie neben den verschlüsselten Daten: in einer separaten Datei, einer ENV-Variable oder einem Secret-Manager.

  • Zwei Algorithmen — AES-256-GCM (OpenSSL) und XSalsa20-Poly1305 (Sodium), beide AEAD
  • Nahtlose DotEnv-Integration — ein Handler, eine Zeile Konfiguration
  • Flexible Key-Provider — aus Datei, ENV-Variable oder eigenem Callable
  • Erweiterbare Resolver-Chain — eigene Verschlüsselungsverfahren hinzufügen
  • Makefile-Tooling — Keys generieren und Werte verschlüsseln per CLI

Installation

bash
composer require jardissupport/secret

GitHub: jardisSupport/secret

PHP-Extensions:

ExtensionFür
ext-opensslAES-256-GCM (Standard)
ext-sodiumXSalsa20-Poly1305 (Alternative)

Grundlegende Nutzung

Wert verschlüsseln (CLI)

bash
# Key-Datei erzeugen (einmalig)
make generate-key-file

# Wert verschlüsseln (AES)
make encrypt VALUE="mein-geheimes-passwort"
# → secret(base64encodedCiphertext...)

# Wert verschlüsseln (Sodium)
make encrypt-sodium VALUE="mein-api-key"
# → secret(sodium:base64encodedCiphertext...)

In .env eintragen

ini
DB_PASSWORD=secret(aGVsbG8gd29ybGQ...)
API_KEY=secret(sodium:c29kaXVtIGVuY3J5cHRlZA...)
REDIS_TOKEN=plaintext-ist-auch-okay

Zur Ladezeit entschlüsseln

php
use JardisSupport\DotEnv\DotEnv;
use JardisSupport\Secret\Handler\SecretHandler;
use JardisSupport\Secret\KeyProvider\FileKeyProvider;

$dotEnv = new DotEnv();
$dotEnv->addHandler(
    new SecretHandler(new FileKeyProvider('support/secret.key')),
    prepend: true
);

$dotEnv->loadPublic(__DIR__);

// $_ENV['DB_PASSWORD'] = 'mein-geheimes-passwort' (entschlüsselt)
// $_ENV['REDIS_TOKEN'] = 'plaintext-ist-auch-okay' (unverändert)

prepend: true ist Pflicht

Der SecretHandler muss vor den Type-Cast-Handlern laufen. Sonst wird secret(...) als String behandelt statt entschlüsselt. Danach durchläuft der Klartext die restliche Pipeline ("true"bool, "42"int).

Algorithmen

AES-256-GCM (Standard)

EigenschaftWert
AlgorithmusAES-256-GCM (authentifizierte Verschlüsselung)
Extensionext-openssl
Key-Länge32 Bytes
Nonce12 Bytes (random, pro Aufruf)
Tag16 Bytes (MAC)
Format in .envsecret(base64...) oder secret(aes:base64...)
php
use JardisSupport\Secret\Resolver\AesSecretResolver;

// Verschlüsseln
$ciphertext = AesSecretResolver::encrypt('mein-passwort', $key);

// Entschlüsseln
$resolver = new AesSecretResolver($key);
$plaintext = $resolver->resolve($ciphertext);

XSalsa20-Poly1305 (Sodium)

EigenschaftWert
AlgorithmusXSalsa20-Poly1305 (libsodium)
Extensionext-sodium
Key-Länge32 Bytes
Nonce24 Bytes (random, pro Aufruf)
Format in .envsecret(sodium:base64...)
php
use JardisSupport\Secret\Resolver\SodiumSecretResolver;

// Verschlüsseln
$ciphertext = SodiumSecretResolver::encrypt('mein-api-key', $key);

// Entschlüsseln
$resolver = new SodiumSecretResolver($key);
$plaintext = $resolver->resolve('sodium:' . $ciphertext);

Beide Algorithmen verwenden authentifizierte Verschlüsselung (AEAD). Manipulierte Ciphertexte werden erkannt und geworfen, nicht still entschlüsselt.

Key-Provider

Der Key muss 32 Bytes lang sein. Drei Wege, ihn bereitzustellen:

FileKeyProvider

Liest den Key aus einer Datei, ideal für Server-Deployments:

php
use JardisSupport\Secret\KeyProvider\FileKeyProvider;

$keyProvider = new FileKeyProvider('/run/secrets/app-key');

Die Datei kann den Key als Raw-Bytes oder Base64-encoded enthalten. Wird automatisch erkannt.

EnvKeyProvider

Liest den Key aus einer Umgebungsvariable, ideal für CI/CD und Cloud:

php
use JardisSupport\Secret\KeyProvider\EnvKeyProvider;

$keyProvider = new EnvKeyProvider('APP_SECRET_KEY');

Custom Callable

Jeder Callable der einen 32-Byte-String zurückgibt:

php
$keyProvider = fn() => file_get_contents('/vault/keys/app');

Alle Provider sind lazy: der Key wird erst beim ersten resolve()-Aufruf geladen.

Resolver-Chain

Die SecretResolverChain probiert Resolver der Reihe nach aus. Der erste, der den Ciphertext erkennt (supports()true), entschlüsselt.

Prefix-Erkennung

CiphertextSodiumAES
sodium:base64...
aes:base64...
base64... (kein Prefix)✓ (Catch-all)
vault:... (fremder Prefix)

AES ist der Catch-all: er greift bei allem ohne erkannten Prefix. Deshalb muss Sodium vor AES in der Chain stehen.

Eigenen Resolver hinzufügen

php
use JardisSupport\Secret\Handler\SecretResolverChain;

$chain = (new SecretResolverChain())
    ->addResolver(new VaultSecretResolver($vaultClient))  // eigener Resolver
    ->addResolver(new SodiumSecretResolver($key))
    ->addResolver(new AesSecretResolver($key));           // Catch-all zuletzt

Ein eigener Resolver implementiert SecretResolverInterface:

php
use JardisSupport\Contract\Secret\SecretResolverInterface;

final class VaultSecretResolver implements SecretResolverInterface
{
    public function supports(string $encryptedValue): bool
    {
        return str_starts_with($encryptedValue, 'vault:');
    }

    public function resolve(string $encryptedValue): string
    {
        $path = substr($encryptedValue, 6);
        return $this->vaultClient->read($path);
    }
}

Fehlerbehandlung

ExceptionUrsache
InvalidKeyExceptionKey fehlt, leer, nicht 32 Bytes, Datei nicht lesbar
DecryptionFailedExceptionUngültiges Base64, zu kurze Daten, MAC-Fehler (manipuliert)
EncryptionFailedExceptionOpenSSL-Fehler bei encrypt()
SecretResolutionExceptionKein Resolver in der Chain erkennt den Wert

Alle erben von SecretExceptionSecretResolutionExceptionRuntimeException.

Architektur

SecretHandler                   ← Convenience-Einstieg (empfohlen)
└── Secret                      ← DotEnv Cast-Handler (__invoke)
    └── SecretResolverChain     ← Chain of Responsibility
        ├── SodiumSecretResolver ← sodium: Prefix
        └── AesSecretResolver    ← aes: Prefix / Catch-all

        KeyProvider (lazy)
        ├── FileKeyProvider      ← Key aus Datei
        └── EnvKeyProvider       ← Key aus ENV-Variable

Verzeichnisstruktur

src/
├── Secret.php                       ← DotEnv Cast-Handler
├── Handler/
│   ├── SecretHandler.php            ← Convenience-Wrapper
│   └── SecretResolverChain.php      ← Chain of Responsibility
├── Resolver/
│   ├── AesSecretResolver.php        ← AES-256-GCM
│   └── SodiumSecretResolver.php     ← XSalsa20-Poly1305
├── KeyProvider/
│   ├── FileKeyProvider.php          ← Key aus Datei
│   └── EnvKeyProvider.php           ← Key aus ENV
└── Exception/
    ├── SecretException.php
    ├── InvalidKeyException.php
    ├── DecryptionFailedException.php
    └── EncryptionFailedException.php

Vollständiges Beispiel

ini
# .env — Secrets verschlüsselt, Rest im Klartext
APP_NAME=MyApp
APP_ENV=production

DB_HOST=db.example.com
DB_PORT=3306
DB_PASSWORD=secret(aGVsbG8gd29ybGQ...)

API_KEY=secret(sodium:c29kaXVtIGVuY3J5cHRlZA...)
REDIS_HOST=redis.example.com
DEBUG=false
php
use JardisSupport\DotEnv\DotEnv;
use JardisSupport\Secret\Handler\SecretHandler;
use JardisSupport\Secret\KeyProvider\FileKeyProvider;

$dotEnv = new DotEnv();
$dotEnv->addHandler(
    new SecretHandler(new FileKeyProvider('/run/secrets/app.key')),
    prepend: true
);

$config = $dotEnv->loadPrivate(__DIR__);

// $config = [
//     'APP_NAME'    => 'MyApp',
//     'APP_ENV'     => 'production',
//     'DB_HOST'     => 'db.example.com',
//     'DB_PORT'     => 3306,
//     'DB_PASSWORD' => 'mein-geheimes-passwort',  // entschlüsselt
//     'API_KEY'     => 'sk-abc123...',             // entschlüsselt
//     'REDIS_HOST'  => 'redis.example.com',
//     'DEBUG'       => false,                       // type-cast nach Entschlüsselung
// ]