323 lines
11 KiB
PHP
323 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Config;
|
|
|
|
use App\Framework\Encryption\EncryptionFactory;
|
|
use App\Framework\Filesystem\ValueObjects\FilePath;
|
|
|
|
/**
|
|
* Enhanced environment loader with encryption support
|
|
*/
|
|
final readonly class EncryptedEnvLoader
|
|
{
|
|
public function __construct(
|
|
private EncryptionFactory $encryptionFactory = new EncryptionFactory,
|
|
private EnvFileParser $parser = new EnvFileParser()
|
|
) {}
|
|
|
|
/**
|
|
* Load environment with automatic encryption support detection
|
|
*
|
|
* This method:
|
|
* 1. Loads Docker ENV vars and .env files
|
|
* 2. Checks for ENCRYPTION_KEY in loaded environment
|
|
* 3. If found, reloads with encryption support for .env.secrets
|
|
*
|
|
* @param FilePath|string $basePath Base path to project root
|
|
* @return Environment Loaded environment with all variables
|
|
*/
|
|
public function load(FilePath|string $basePath): Environment
|
|
{
|
|
// First pass: Load environment (Docker ENV + .env files)
|
|
$env = $this->loadEnvironment($basePath);
|
|
|
|
// Check if we have encryption key for secrets
|
|
$encryptionKey = $env->get('ENCRYPTION_KEY');
|
|
|
|
if ($encryptionKey === null) {
|
|
// No encryption key, return environment as-is
|
|
return $env;
|
|
}
|
|
|
|
try {
|
|
// Second pass: Reload with encryption support for .env.secrets
|
|
return $this->loadEnvironment($basePath, $encryptionKey);
|
|
} catch (\Throwable $e) {
|
|
// Fallback to environment without secrets if encryption fails
|
|
error_log("Failed to load encrypted secrets: " . $e->getMessage());
|
|
error_log("Continuing with non-encrypted environment variables.");
|
|
|
|
return $env;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load environment from multiple files with encryption support
|
|
*
|
|
* Priority Strategy:
|
|
* - Production: Docker ENV vars take precedence over .env files
|
|
* - Development: .env files can override system environment
|
|
*/
|
|
public function loadEnvironment(FilePath|string $basePath, ?string $encryptionKey = null): Environment
|
|
{
|
|
// Start with system environment variables (Docker/OS)
|
|
$systemVariables = $this->loadSystemEnvironment();
|
|
|
|
// Determine environment type from system variables first
|
|
$appEnv = $systemVariables['APP_ENV'] ?? 'development';
|
|
$isProduction = $appEnv === 'production';
|
|
|
|
$baseDir = $basePath instanceof FilePath ? $basePath : FilePath::create($basePath);
|
|
|
|
// For PRODUCTION: Docker ENV vars have highest priority
|
|
// For DEVELOPMENT: .env files can override system env
|
|
if ($isProduction) {
|
|
// Production: System ENV → .env files (only if not set)
|
|
$variables = $systemVariables;
|
|
|
|
// Load .env files but DON'T override Docker ENV vars
|
|
$envFile = $baseDir->join('.env');
|
|
if ($envFile->exists()) {
|
|
$fileVariables = $this->parser->parse($envFile);
|
|
// Only add variables that aren't already set by Docker
|
|
foreach ($fileVariables as $key => $value) {
|
|
if (!isset($variables[$key])) {
|
|
$variables[$key] = $value;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Development: .env files → System ENV (local development workflow)
|
|
$variables = $systemVariables;
|
|
|
|
// Load base .env file (can override system env for development)
|
|
$envFile = $baseDir->join('.env');
|
|
if ($envFile->exists()) {
|
|
$variables = array_merge($variables, $this->parser->parse($envFile));
|
|
}
|
|
}
|
|
|
|
// Load .env.secrets file if it exists and encryption key is provided
|
|
$secretsFile = $baseDir->join('.env.secrets');
|
|
if ($secretsFile->exists() && $encryptionKey !== null) {
|
|
$secretsVariables = $this->parser->parse($secretsFile);
|
|
$variables = array_merge($variables, $secretsVariables);
|
|
}
|
|
|
|
// Re-check APP_ENV after .env loading
|
|
$appEnv = $variables['APP_ENV'] ?? 'development';
|
|
|
|
// Load environment-specific files
|
|
$envSpecificFile = $baseDir->join(".env.{$appEnv}");
|
|
if ($envSpecificFile->exists()) {
|
|
if ($isProduction) {
|
|
// Production: Only add missing variables
|
|
$envSpecificVariables = $this->parser->parse($envSpecificFile);
|
|
foreach ($envSpecificVariables as $key => $value) {
|
|
if (!isset($variables[$key])) {
|
|
$variables[$key] = $value;
|
|
}
|
|
}
|
|
} else {
|
|
// Development: Allow override
|
|
$variables = array_merge($variables, $this->parser->parse($envSpecificFile));
|
|
}
|
|
}
|
|
|
|
return new Environment($variables);
|
|
}
|
|
|
|
/**
|
|
* Load variables from system environment (getenv(), $_ENV, $_SERVER)
|
|
*
|
|
* Priority order optimized for PHP-FPM compatibility:
|
|
* 1. getenv() - Works in PHP-FPM, reads from actual process environment
|
|
* 2. $_ENV - May be empty in PHP-FPM
|
|
* 3. $_SERVER - May contain additional vars from web server
|
|
*/
|
|
private function loadSystemEnvironment(): array
|
|
{
|
|
$variables = [];
|
|
|
|
// 1. Load from getenv() - THIS WORKS IN PHP-FPM!
|
|
// In PHP-FPM, $_ENV and $_SERVER are empty by default.
|
|
// getenv() reads from actual process environment, unlike $_ENV
|
|
$allEnvVars = getenv();
|
|
if ($allEnvVars !== false) {
|
|
foreach ($allEnvVars as $key => $value) {
|
|
$variables[$key] = $value;
|
|
}
|
|
}
|
|
|
|
// 2. Load from $_ENV (may be empty in PHP-FPM)
|
|
foreach ($_ENV as $key => $value) {
|
|
if (!isset($variables[$key])) {
|
|
$variables[$key] = $value;
|
|
}
|
|
}
|
|
|
|
// 3. Load from $_SERVER (may contain additional vars from web server)
|
|
foreach ($_SERVER as $key => $value) {
|
|
if (!isset($variables[$key]) && is_string($value)) {
|
|
$variables[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return $variables;
|
|
}
|
|
|
|
/**
|
|
* Generate a .env.secrets template file
|
|
*/
|
|
public function generateSecretsTemplate(FilePath|string $basePath, array $secretKeys = []): FilePath
|
|
{
|
|
$template = "# .env.secrets - Encrypted secrets file\n";
|
|
$template .= "# Generated on " . date('Y-m-d H:i:s') . "\n";
|
|
$template .= "# \n";
|
|
$template .= "# Instructions:\n";
|
|
$template .= "# 1. Set ENCRYPTION_KEY in your main .env file\n";
|
|
$template .= "# 2. Use SecretManager::encryptSecret() to encrypt sensitive values\n";
|
|
$template .= "# 3. Store encrypted values in this file with ENC[...] format\n";
|
|
$template .= "# \n";
|
|
$template .= "# Example:\n";
|
|
$template .= "# SECRET_API_KEY=ENC[base64encodedencryptedvalue]\n";
|
|
$template .= "# SECRET_DATABASE_PASSWORD=ENC[anotherencryptedvalue]\n";
|
|
$template .= "\n";
|
|
|
|
// Add predefined secret keys
|
|
$defaultSecretKeys = [
|
|
'SECRET_ENCRYPTION_KEY',
|
|
'SECRET_DATABASE_PASSWORD',
|
|
'SECRET_API_KEY',
|
|
'SECRET_JWT_SECRET',
|
|
'SECRET_OAUTH_CLIENT_SECRET',
|
|
'SECRET_SMTP_PASSWORD',
|
|
'SECRET_REDIS_PASSWORD',
|
|
];
|
|
|
|
$allSecretKeys = array_unique(array_merge($defaultSecretKeys, $secretKeys));
|
|
|
|
foreach ($allSecretKeys as $key) {
|
|
$template .= "# {$key}=ENC[your_encrypted_value_here]\n";
|
|
}
|
|
|
|
$baseDir = $basePath instanceof FilePath ? $basePath : FilePath::create($basePath);
|
|
$filePath = $baseDir->join('.env.secrets');
|
|
file_put_contents($filePath->toString(), $template);
|
|
|
|
return $filePath;
|
|
}
|
|
|
|
/**
|
|
* Encrypt secrets in an existing .env file
|
|
*/
|
|
public function encryptSecretsInFile(FilePath|string $filePath, string $encryptionKey, array $keysToEncrypt = []): int
|
|
{
|
|
$file = $filePath instanceof FilePath ? $filePath : FilePath::create($filePath);
|
|
|
|
if (! $file->exists() || ! $file->isReadable()) {
|
|
return 0;
|
|
}
|
|
|
|
$encryption = $this->encryptionFactory->createBest($encryptionKey);
|
|
$lines = file($file->toString(), FILE_IGNORE_NEW_LINES);
|
|
if ($lines === false) {
|
|
return 0;
|
|
}
|
|
|
|
$encryptedCount = 0;
|
|
$newLines = [];
|
|
|
|
foreach ($lines as $line) {
|
|
$trimmedLine = trim($line);
|
|
|
|
// Skip comments and empty lines
|
|
if (str_starts_with($trimmedLine, '#') || ! str_contains($trimmedLine, '=')) {
|
|
$newLines[] = $line;
|
|
|
|
continue;
|
|
}
|
|
|
|
$equalPos = strpos($trimmedLine, '=');
|
|
if ($equalPos === false) {
|
|
$newLines[] = $line;
|
|
|
|
continue;
|
|
}
|
|
|
|
$name = trim(substr($trimmedLine, 0, $equalPos));
|
|
$value = trim(substr($trimmedLine, $equalPos + 1));
|
|
|
|
// Check if this key should be encrypted
|
|
$shouldEncrypt = empty($keysToEncrypt) ? str_starts_with($name, 'SECRET_') : in_array($name, $keysToEncrypt, true);
|
|
|
|
if ($shouldEncrypt && ! $encryption->isEncrypted($value)) {
|
|
$value = $this->parser->removeQuotes($value);
|
|
$encryptedValue = $encryption->encrypt($value);
|
|
$newLines[] = "{$name}={$encryptedValue}";
|
|
$encryptedCount++;
|
|
} else {
|
|
$newLines[] = $line;
|
|
}
|
|
}
|
|
|
|
// Write back to file
|
|
file_put_contents($file->toString(), implode("\n", $newLines));
|
|
|
|
return $encryptedCount;
|
|
}
|
|
|
|
/**
|
|
* Validate encryption setup
|
|
*/
|
|
public function validateEncryptionSetup(FilePath|string $basePath, ?string $encryptionKey = null): array
|
|
{
|
|
$issues = [];
|
|
|
|
// Check if encryption key is provided
|
|
if ($encryptionKey === null) {
|
|
$issues[] = [
|
|
'type' => 'missing_key',
|
|
'message' => 'No encryption key provided',
|
|
'severity' => 'high',
|
|
];
|
|
|
|
return $issues;
|
|
}
|
|
|
|
// Check key strength
|
|
if (strlen($encryptionKey) < 32) {
|
|
$issues[] = [
|
|
'type' => 'weak_key',
|
|
'message' => 'Encryption key should be at least 32 characters',
|
|
'severity' => 'high',
|
|
];
|
|
}
|
|
|
|
// Check if .env.secrets exists
|
|
$baseDir = $basePath instanceof FilePath ? $basePath : FilePath::create($basePath);
|
|
$secretsFile = $baseDir->join('.env.secrets');
|
|
if (! $secretsFile->exists()) {
|
|
$issues[] = [
|
|
'type' => 'missing_secrets_file',
|
|
'message' => '.env.secrets file not found',
|
|
'severity' => 'medium',
|
|
];
|
|
}
|
|
|
|
// Check encryption availability
|
|
$availableMethods = $this->encryptionFactory->getAvailableMethods();
|
|
if (! in_array('AES', $availableMethods, true)) {
|
|
$issues[] = [
|
|
'type' => 'weak_encryption',
|
|
'message' => 'OpenSSL not available, using basic encryption',
|
|
'severity' => 'medium',
|
|
];
|
|
}
|
|
|
|
return $issues;
|
|
}
|
|
}
|