feat: encrypting & config
This commit is contained in:
Executable
+80
@@ -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);
|
||||
}
|
||||
@@ -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' => [],
|
||||
];
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user