MidПрактика16 min

Управление секретами

HashiCorp Vault, переменные окружения, ротация секретов и Symfony Secrets

Что такое секреты

Секреты — конфиденциальные данные, необходимые для работы приложения: пароли БД, API-ключи, сертификаты, токены.

Где НЕ хранить секреты

Место Почему плохо
Исходный код Попадёт в Git, видно всем разработчикам
.env в репозитории Тот же риск, что и с кодом
Docker image Секрет запечён в слой, видно через docker history
Логи Случайное логирование секретов
Комментарии/README Забытые секреты в документации
Переменные CI/CD без шифрования Видно в логах pipeline

Иерархия хранения секретов

Уровень 1: Vault / AWS Secrets Manager (best)
  └── Централизованное хранение, ротация, аудит

Уровень 2: Symfony Secrets (encrypted in repo)
  └── Зашифрованные секреты в репозитории

Уровень 3: Environment variables (ok)
  └── Через deployment tool, не в .env файле

Уровень 4: .env.local (dev only)
  └── Только для локальной разработки, в .gitignore

Symfony Secrets

Symfony Secrets шифрует секреты с помощью libsodium и хранит зашифрованные значения прямо в репозитории.

<?php

declare(strict_types=1);

// Symfony Secrets workflow:
// 1. Generate keys: bin/console secrets:generate-keys
// 2. Set a secret: bin/console secrets:set DATABASE_URL
// 3. List secrets: bin/console secrets:list
// 4. Remove secret: bin/console secrets:remove DATABASE_URL

// Encrypted files stored in:
//   config/secrets/prod/prod.DATABASE_URL.28a3bc.php  (encrypted value)
//   config/secrets/prod/prod.decrypt.private.php      (private key - NOT in git)

// In services, secrets are injected as parameters:
namespace App\Service;

final readonly class PaymentGateway
{
    public function __construct(
        // Injected from Symfony Secrets or .env.
        // #[\SensitiveParameter] redacts the value in stack traces and error
        // reports — otherwise one uncaught exception prints the live key
        #[\SensitiveParameter] private string $stripeSecretKey,    // %env(STRIPE_SECRET_KEY)%
        #[\SensitiveParameter] private string $stripeWebhookSecret, // %env(STRIPE_WEBHOOK_SECRET)%
    ) {}

    public function createCharge(int $amount, string $currency): array
    {
        // Use the injected secret — never hardcode
        return $this->callStripeApi('/charges', [
            'amount' => $amount,
            'currency' => $currency,
        ]);
    }

    /**
     * @param array<string, scalar> $data
     * @return array<string, mixed>
     */
    private function callStripeApi(string $endpoint, array $data): array
    {
        $ch = curl_init("https://api.stripe.com/v1{$endpoint}");
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => http_build_query($data),
            CURLOPT_USERPWD => $this->stripeSecretKey . ':',
            CURLOPT_TIMEOUT => 10,
        ]);

        $response = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($response === false || $statusCode >= 400) {
            // Surface the status only — the response body can echo back
            // request data, and the message may reach a log or a user
            throw new \RuntimeException("Payment API call failed: HTTP {$statusCode}");
        }

        return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
    }
}
## Ротация секретов

Ротация — регулярная смена секретов для снижения риска компрометации.

Стратегии ротации

Стратегия Описание Downtime
Blue-Green Два набора credentials, переключение Нет
Graceful Новый секрет + grace period для старого Нет
Big Bang Замена всех разом Возможен
Automated Vault/AWS автоматически ротирует Нет

Поддержка ротации

<?php

declare(strict_types=1);

namespace App\Security;

/**
 * Supports secret rotation by accepting multiple valid secrets.
 * During rotation, both old and new secrets are valid.
 */
final readonly class RotatableApiKeyValidator
{
    /**
     * @param array<string> $validKeys Current + previous valid API keys
     */
    public function __construct(
        private array $validKeys,
    ) {}

    /**
     * Validate an API key. Accepts any key from the valid set.
     * This allows zero-downtime rotation.
     */
    public function validate(string $apiKey): ValidationResult
    {
        $result = new ValidationResult(valid: false, isLatest: false);

        foreach ($this->validKeys as $index => $validKey) {
            // hash_equals is constant-time, but returning on the first match
            // is not: the loop deliberately runs to the end so the response
            // time does not reveal which key in the set matched
            if (hash_equals($validKey, $apiKey)) {
                $result = new ValidationResult(
                    valid: true,
                    isLatest: $index === 0,
                );
            }
        }

        return $result;
    }
}

final readonly class ValidationResult
{
    public function __construct(
        public bool $valid,
        public bool $isLatest, // false = client should update their key
    ) {}
}
## HashiCorp Vault

Vault — система централизованного управления секретами с аудитом, ротацией и контролем доступа.

Клиент для Vault

<?php

declare(strict_types=1);

namespace App\Secrets;

final class VaultClient
{
    public function __construct(
        private readonly string $vaultUrl,
        private readonly string $token,
    ) {}

    /**
     * Read a secret from Vault KV v2 engine.
     *
     * @return array<string, mixed>
     */
    public function getSecret(string $path): array
    {
        $url = "{$this->vaultUrl}/v1/secret/data/{$path}";

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                "X-Vault-Token: {$this->token}",
                'Content-Type: application/json',
            ],
            CURLOPT_TIMEOUT => 5,
        ]);

        $response = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($response === false || $statusCode !== 200) {
            // The status code is safe to surface; the response body is not
            throw new \RuntimeException("Vault error: HTTP {$statusCode}");
        }

        $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);

        return $data['data']['data'] ?? [];
    }

    /**
     * Write a secret to Vault.
     */
    public function putSecret(string $path, array $data): void
    {
        $url = "{$this->vaultUrl}/v1/secret/data/{$path}";

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST => 'POST',
            CURLOPT_POSTFIELDS => json_encode(['data' => $data]),
            CURLOPT_HTTPHEADER => [
                "X-Vault-Token: {$this->token}",
                'Content-Type: application/json',
            ],
            CURLOPT_TIMEOUT => 5,
        ]);

        $response = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($response === false || ($statusCode !== 200 && $statusCode !== 204)) {
            throw new \RuntimeException("Vault write error: HTTP {$statusCode}");
        }
    }
}
### Кеширование секретов из Vault
<?php

declare(strict_types=1);

namespace App\Secrets;

final class CachedSecretProvider
{
    /** @var array<string, array{value: string, expires_at: float}> */
    private array $cache = [];

    public function __construct(
        private readonly VaultClient $vault,
        private readonly int $cacheTtlSeconds = 300, // 5 minutes
    ) {}

    /**
     * Get secret with in-memory caching.
     * Reduces Vault API calls while keeping secrets fresh.
     */
    public function get(string $path, string $key): string
    {
        $cacheKey = "{$path}:{$key}";

        if (isset($this->cache[$cacheKey])
            && $this->cache[$cacheKey]['expires_at'] > microtime(true)
        ) {
            return $this->cache[$cacheKey]['value'];
        }

        $secrets = $this->vault->getSecret($path);
        $value = $secrets[$key] ?? null;

        // Validate the shape before caching. The message names the path and
        // the key, never the value — this exception may reach a log
        if (!is_string($value)) {
            throw new \RuntimeException(
                "Secret key '{$key}' at path '{$path}' is missing or not a string",
            );
        }

        $this->cache[$cacheKey] = [
            'value' => $value,
            'expires_at' => microtime(true) + $this->cacheTtlSeconds,
        ];

        return $value;
    }
}
## Аудит секретов
Что логировать Зачем
Доступ к секрету Кто и когда читал
Изменение секрета Кто и когда менял
Неудачные попытки Обнаружение атак
Ротация Подтверждение процесса

Правило: Никогда не логируйте значения секретов. Логируйте только факт доступа (кто, когда, какой секрет, результат).

Итоги

Концепция Суть
Symfony Secrets Зашифрованные секреты в репозитории
Vault Централизованное управление + аудит
Ротация Регулярная смена, zero-downtime
.env.local Только для dev, в .gitignore
Audit logging Логировать доступ, не значения