Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,118 @@
using System.Security.Cryptography;
using System.Text;
using Deal.Modules.Settings.Application;
namespace Deal.Infrastructure.Security;
/// <summary>
/// AES-256-GCM-шифр секретов (Ruling 2): nonce 12 байт, tag 16 байт, ключ 32 байта.
/// </summary>
/// <remarks>
/// Формат значения: <c>enc:</c> + Base64(nonce ‖ шифротекст ‖ tag). Экземпляр AesGcm создаётся
/// на каждую операцию: операции шифрования секретов редкие, а отсутствие разделяемого
/// криптографического состояния делает singleton-использование потокобезопасным.
/// </remarks>
public sealed class AesGcmSecretCipher : ISecretCipher
{
// Префикс зашифрованного значения (маркер формата в хранилище).
private const string EncryptedPrefix = "enc:";
// Размер nonce AES-GCM (рекомендованный NIST — 96 бит).
private const int NonceSizeBytes = 12;
// Размер тега аутентичности.
private const int TagSizeBytes = 16;
// Размер ключа AES-256.
private const int KeySizeBytes = 32;
private readonly byte[] _key;
/// <summary>
/// Создаёт шифр с фиксированным ключом.
/// </summary>
/// <param name="key">Ключ AES-256 (32 байта), поставляется EncryptionKeyProvider.</param>
/// <exception cref="ArgumentException">Длина ключа не равна 32 байтам.</exception>
public AesGcmSecretCipher(byte[] key)
{
ArgumentNullException.ThrowIfNull(key);
if (key.Length != KeySizeBytes)
{
throw new ArgumentException($"Ключ AES-256 должен быть длиной {KeySizeBytes} байта; получено {key.Length}.", nameof(key));
}
_key = key;
}
/// <inheritdoc />
public string Encrypt(string plainText)
{
if (string.IsNullOrEmpty(plainText))
{
return string.Empty;
}
byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] cipherBytes = new byte[plainBytes.Length];
byte[] tag = new byte[TagSizeBytes];
using (AesGcm aesGcm = new AesGcm(_key, TagSizeBytes))
{
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
}
byte[] payload = new byte[nonce.Length + cipherBytes.Length + tag.Length];
nonce.CopyTo(payload, 0);
cipherBytes.CopyTo(payload, nonce.Length);
tag.CopyTo(payload, nonce.Length + cipherBytes.Length);
return EncryptedPrefix + Convert.ToBase64String(payload);
}
/// <inheritdoc />
public string Decrypt(string cipherText)
{
if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
{
return string.Empty;
}
byte[] payload;
try
{
payload = Convert.FromBase64String(cipherText[EncryptedPrefix.Length..]);
}
catch (FormatException)
{
// Значение не является корректным Base64 — это не наш формат.
return string.Empty;
}
if (payload.Length < NonceSizeBytes + TagSizeBytes)
{
// Слишком короткий payload: nonce и tag в нём не помещаются.
return string.Empty;
}
int cipherTextLength = payload.Length - NonceSizeBytes - TagSizeBytes;
byte[] plainBytes = new byte[cipherTextLength];
try
{
using AesGcm aesGcm = new AesGcm(_key, TagSizeBytes);
aesGcm.Decrypt(
new ReadOnlySpan<byte>(payload, 0, NonceSizeBytes),
new ReadOnlySpan<byte>(payload, NonceSizeBytes, cipherTextLength),
new ReadOnlySpan<byte>(payload, NonceSizeBytes + cipherTextLength, TagSizeBytes),
plainBytes);
}
catch (CryptographicException)
{
// Повреждённый tag, чужой ключ или битый шифротекст — секрет недоступен.
return string.Empty;
}
return Encoding.UTF8.GetString(plainBytes);
}
}
@@ -0,0 +1,143 @@
using System.Security.Cryptography;
namespace Deal.Infrastructure.Security;
/// <summary>
/// Источник ключа шифрования секретов (Ruling 2): env-ключ либо файл data/encryption.key.
/// </summary>
/// <remarks>
/// Порядок разрешения — как в crypto._get_fernet (crypto.py L2242):
/// <list type="number">
/// <item><description>env <c>DEAL_ENCRYPTION_KEY</c> — 32 байта в urlsafe-Base64; невалидный ключ → исключение при старте;</description></item>
/// <item><description>иначе файл <c>data/encryption.key</c> относительно ContentRoot (путь переопределяется env
/// <c>DEAL_ENCRYPTION_KEY_FILE</c>); при первом старте файл генерируется (32 случайных байта, urlsafe-Base64).</description></item>
/// </list>
/// Ключ кэшируется после первого разрешения. Для продакшена задавайте env-ключ, а не файл.
/// </remarks>
public sealed class EncryptionKeyProvider
{
// Имя env-переменной с ключом (32 байта, urlsafe-Base64).
private const string KeyEnvironmentVariableName = "DEAL_ENCRYPTION_KEY";
// Имя env-переменной, переопределяющей путь к файлу-ключу.
private const string KeyFileEnvironmentVariableName = "DEAL_ENCRYPTION_KEY_FILE";
// Путь к файлу-ключу по умолчанию относительно ContentRoot.
private const string DefaultKeyFileRelativePath = "data/encryption.key";
// Размер ключа AES-256.
private const int KeySizeBytes = 32;
private readonly string _keyFilePath;
private byte[]? _cachedKey;
/// <summary>
/// Создаёт провайдер для корня контента приложения.
/// </summary>
/// <param name="contentRootPath">ContentRoot приложения (каталог по умолчанию для файла-ключа).</param>
public EncryptionKeyProvider(string contentRootPath)
{
string? keyFileOverride = Environment.GetEnvironmentVariable(KeyFileEnvironmentVariableName);
_keyFilePath = string.IsNullOrWhiteSpace(keyFileOverride)
? Path.Combine(contentRootPath, DefaultKeyFileRelativePath)
: keyFileOverride;
}
/// <summary>
/// Возвращает ключ AES-256 (32 байта), разрешая его один раз и кэшируя результат.
/// </summary>
/// <returns>Ключ шифрования.</returns>
/// <exception cref="InvalidOperationException">Env-ключ невалиден либо файл-ключ невозможно использовать.</exception>
public byte[] GetKey() => _cachedKey ??= ResolveKey();
private byte[] ResolveKey()
{
string? keyFromEnvironment = Environment.GetEnvironmentVariable(KeyEnvironmentVariableName);
if (!string.IsNullOrWhiteSpace(keyFromEnvironment))
{
return DecodeKey(keyFromEnvironment, KeyEnvironmentVariableName);
}
try
{
return ResolveKeyFromFile();
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
throw new InvalidOperationException(
$"Не удалось использовать файл ключа шифрования «{_keyFilePath}». Задайте переменную окружения {KeyEnvironmentVariableName} (32 байта в urlsafe-Base64).",
exception);
}
}
private byte[] ResolveKeyFromFile()
{
if (!File.Exists(_keyFilePath))
{
return GenerateKeyFile();
}
string fileContent = File.ReadAllText(_keyFilePath).Trim();
if (fileContent.Length == 0)
{
throw new InvalidOperationException(
$"Файл ключа шифрования «{_keyFilePath}» пуст: удалите его — при следующем старте ключ сгенерируется заново.");
}
return DecodeKey(fileContent, _keyFilePath);
}
private byte[] GenerateKeyFile()
{
byte[] key = RandomNumberGenerator.GetBytes(KeySizeBytes);
string? directoryPath = Path.GetDirectoryName(_keyFilePath);
if (!string.IsNullOrEmpty(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
File.WriteAllText(_keyFilePath, ToUrlSafeBase64(key));
return key;
}
private static byte[] DecodeKey(string encodedKey, string keySource)
{
byte[] key;
try
{
key = Convert.FromBase64String(NormalizeUrlSafeBase64(encodedKey));
}
catch (FormatException)
{
throw new InvalidOperationException(
$"Ключ из «{keySource}» не является корректным urlsafe-Base64.");
}
if (key.Length != KeySizeBytes)
{
throw new InvalidOperationException(
$"Ключ из «{keySource}» декодируется в {key.Length} байт, а ожидается {KeySizeBytes} (AES-256).");
}
return key;
}
// Приводит urlsafe-алфавит (-_) к стандартному (+/) и дополняет padding до кратности 4.
private static string NormalizeUrlSafeBase64(string value)
{
string normalized = value.Replace('-', '+').Replace('_', '/');
switch (normalized.Length % 4)
{
case 2:
return normalized + "==";
case 3:
return normalized + "=";
default:
return normalized;
}
}
private static string ToUrlSafeBase64(byte[] value) =>
Convert.ToBase64String(value).Replace('+', '-').Replace('/', '_');
}