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
composer require jardissupport/secretGitHub: jardisSupport/secret
PHP-Extensions:
| Extension | Für |
|---|---|
ext-openssl | AES-256-GCM (Standard) |
ext-sodium | XSalsa20-Poly1305 (Alternative) |
Grundlegende Nutzung
Wert verschlüsseln (CLI)
# 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
DB_PASSWORD=secret(aGVsbG8gd29ybGQ...)
API_KEY=secret(sodium:c29kaXVtIGVuY3J5cHRlZA...)
REDIS_TOKEN=plaintext-ist-auch-okayZur Ladezeit entschlüsseln
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)
| Eigenschaft | Wert |
|---|---|
| Algorithmus | AES-256-GCM (authentifizierte Verschlüsselung) |
| Extension | ext-openssl |
| Key-Länge | 32 Bytes |
| Nonce | 12 Bytes (random, pro Aufruf) |
| Tag | 16 Bytes (MAC) |
| Format in .env | secret(base64...) oder secret(aes:base64...) |
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)
| Eigenschaft | Wert |
|---|---|
| Algorithmus | XSalsa20-Poly1305 (libsodium) |
| Extension | ext-sodium |
| Key-Länge | 32 Bytes |
| Nonce | 24 Bytes (random, pro Aufruf) |
| Format in .env | secret(sodium:base64...) |
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:
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:
use JardisSupport\Secret\KeyProvider\EnvKeyProvider;
$keyProvider = new EnvKeyProvider('APP_SECRET_KEY');Custom Callable
Jeder Callable der einen 32-Byte-String zurückgibt:
$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
| Ciphertext | Sodium | AES |
|---|---|---|
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
use JardisSupport\Secret\Handler\SecretResolverChain;
$chain = (new SecretResolverChain())
->addResolver(new VaultSecretResolver($vaultClient)) // eigener Resolver
->addResolver(new SodiumSecretResolver($key))
->addResolver(new AesSecretResolver($key)); // Catch-all zuletztEin eigener Resolver implementiert SecretResolverInterface:
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
| Exception | Ursache |
|---|---|
InvalidKeyException | Key fehlt, leer, nicht 32 Bytes, Datei nicht lesbar |
DecryptionFailedException | Ungültiges Base64, zu kurze Daten, MAC-Fehler (manipuliert) |
EncryptionFailedException | OpenSSL-Fehler bei encrypt() |
SecretResolutionException | Kein Resolver in der Chain erkennt den Wert |
Alle erben von SecretException → SecretResolutionException → RuntimeException.
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-VariableVerzeichnisstruktur
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.phpVollständiges Beispiel
# .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=falseuse 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
// ]