feat: encrypting & config

This commit is contained in:
2026-10-12 04:32:01 +03:00
parent 9e5a2b5bce
commit 41281501b8
10 changed files with 440 additions and 20 deletions
Executable
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use App\Config\Config;
use App\Crypto\Encryptor;
require __DIR__ . '/../vendor/autoload.php';
$format = static function (mixed $value): string {
return match (true) {
is_bool($value) => $value ? 'true' : 'false',
$value === null => 'null',
is_array($value) => json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]',
default => (string) $value,
};
};
$command = $argv[1] ?? null;
try {
switch ($command) {
case 'keygen':
echo Encryptor::generateKey(), "\n";
echo "Помести ключ в APP_KEY (.env)\n";
break;
case 'encrypt':
if (!isset($argv[2])) {
throw new \RuntimeException('Укажи значение: config encrypt <value>');
}
echo Encryptor::fromEnv()->encrypt($argv[2]), "\n";
break;
case 'decrypt':
if (!isset($argv[2])) {
throw new \RuntimeException('Укажи значение: config decrypt <value>');
}
echo Encryptor::fromEnv()->decrypt($argv[2]), "\n";
break;
case 'show':
$config = Config::load();
$filter = $argv[2] ?? null;
$values = $config->all();
if ($filter !== null) {
$values = array_filter(
$values,
static fn (string $key): bool => $key === $filter || str_starts_with($key, $filter . '.'),
ARRAY_FILTER_USE_KEY,
);
}
if ($values === []) {
fwrite(STDERR, "Значения не найдены\n");
exit(1);
}
$width = max(array_map('strlen', array_keys($values)));
foreach ($values as $key => $value) {
echo str_pad($key, $width), ' = ', $format($value), "\n";
}
break;
default:
fwrite(STDERR, "Использование: config <keygen|encrypt|decrypt|show> [args]\n");
fwrite(STDERR, " keygen - сгенерировать новый APP_KEY\n");
fwrite(STDERR, " encrypt <value> - зашифровать значение\n");
fwrite(STDERR, " decrypt <value> - расшифровать значение\n");
fwrite(STDERR, " show [key] - показать конфиг текущего APP_ENV\n");
exit(1);
}
exit(0);
} catch (Throwable $e) {
fwrite(STDERR, 'Ошибка конфигурации: ' . $e->getMessage() . "\n");
exit(1);
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'default' => [
'name' => 'webl',
'debug' => false,
'timezone' => 'Europe/Moscow',
],
'local' => [
'debug' => true,
],
'dev' => [
'debug' => true,
],
'prod' => [],
];
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
return [
'default' => [
'driver' => 'pgsql',
'host' => 'db',
'port' => 5432,
'database' => 'webl',
'username' => 'webl',
],
'local' => [
'driver' => 'pgsql',
'host' => 'db',
'port' => 5432,
'database' => 'webl',
'username' => 'webl',
],
'prod' => [
'debug_sql' => false,
],
];
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Config;
use App\Crypto\Encryptor;
final class Config {
private static ?self $instance = null;
private function __construct(
private readonly string $env,
private readonly array $sections,
private readonly array $data,
) {
}
public static function load(?string $env = null): self {
$env ??= (getenv('APP_ENV') ?: 'local');
$encryptor = null;
$sections = [];
foreach (glob(__DIR__ . '/../../config/*.php') ?: [] as $file) {
$section = basename($file, '.php');
$data = require $file;
if (!is_array($data)) {
continue;
}
$sections[$section] = array_replace_recursive($data['default'] ?? [], $data[$env] ?? []);
}
foreach ($sections as $section => $values) {
$sections[$section] = self::decryptValues($values, $section, $env, $encryptor);
}
$data = [];
foreach ($sections as $section => $values) {
foreach (self::flatten($values, $section) as $key => $value) {
$data[$key] = $value;
}
}
return self::$instance = new self($env, $sections, $data);
}
public static function instance(): self {
return self::$instance ??= self::load();
}
public static function get(string $key, mixed $default = null): mixed {
$config = self::instance();
if (array_key_exists($key, $config->data)) {
return $config->data[$key];
}
$value = $config->sections;
foreach (explode('.', $key) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return $default;
}
$value = $value[$segment];
}
return $value;
}
public function all(): array {
return $this->data;
}
public function env(): string {
return $this->env;
}
private static function decryptValues(array $values, string $prefix, string $env, ?Encryptor &$encryptor): array {
foreach ($values as $key => $value) {
$dotKey = $prefix . '.' . $key;
if (is_array($value)) {
$values[$key] = self::decryptValues($value, $dotKey, $env, $encryptor);
continue;
}
if (!is_string($value) || !str_starts_with($value, Encryptor::MARKER)) {
continue;
}
try {
$encryptor ??= Encryptor::fromEnv();
$values[$key] = $encryptor->decrypt($value);
} catch (\Throwable $e) {
if ($env === 'prod') {
throw $e;
}
\App\Logger::get()->warning('config.decrypt_failed', [
'key' => $dotKey,
'error' => $e->getMessage(),
]);
$values[$key] = null;
}
}
return $values;
}
private static function flatten(array $values, string $prefix): array {
$flat = [];
foreach ($values as $key => $value) {
$dotKey = $prefix . '.' . $key;
if (is_array($value) && $value !== []) {
$flat += self::flatten($value, $dotKey);
continue;
}
$flat[$dotKey] = $value;
}
return $flat;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Crypto;
final class Encryptor {
public const MARKER = 'enc:';
public const CIPHER = 'aes-256-gcm';
public const VERSION = 'v1';
private const IV_LENGTH = 12;
private const TAG_LENGTH = 16;
private function __construct(private readonly string $key) {
}
public static function create(string $base64Key): self {
$key = base64_decode($base64Key, true);
if ($key === false || strlen($key) !== 32) {
throw new \RuntimeException('APP_KEY должен быть base64 строкой из 32 байт, для генерации используй: make keygen');
}
return new self($key);
}
public static function fromEnv(): self {
$key = getenv('APP_KEY');
if ($key === false || $key === '') {
throw new \RuntimeException('Нету APP_KEY, сгенерируйте при помощи: make keygen');
}
return self::create($key);
}
public static function generateKey(): string {
return base64_encode(random_bytes(32));
}
public function encrypt(string $plaintext): string {
$iv = random_bytes(self::IV_LENGTH);
$tag = '';
$ciphertext = openssl_encrypt($plaintext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LENGTH);
if ($ciphertext === false) {
throw new \RuntimeException('Не удалось зашифровать');
}
return self::MARKER . self::VERSION . ':' . base64_encode($iv . $tag . $ciphertext);
}
public function decrypt(string $payload): string {
if (str_starts_with($payload, self::MARKER)) {
$payload = substr($payload, strlen(self::MARKER));
}
if (!str_starts_with($payload, self::VERSION . ':')) {
throw new \RuntimeException('Неизвестная версия шифра');
}
$raw = base64_decode(substr($payload, strlen(self::VERSION) + 1), true);
if ($raw === false || strlen($raw) < self::IV_LENGTH + self::TAG_LENGTH) {
throw new \RuntimeException('Битый шифр');
}
$iv = substr($raw, 0, self::IV_LENGTH);
$tag = substr($raw, self::IV_LENGTH, self::TAG_LENGTH);
$ciphertext = substr($raw, self::IV_LENGTH + self::TAG_LENGTH);
$plaintext = openssl_decrypt($ciphertext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag);
if ($plaintext === false) {
throw new \RuntimeException('Не удалось расшифровать: тег не тот');
}
return $plaintext;
}
public function isEncrypted(string $value): bool {
return str_starts_with($value, self::MARKER);
}
public function decryptIfEncrypted(string $value): string {
return $this->isEncrypted($value) ? $this->decrypt($value) : $value;
}
}