Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -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 L22–42):
|
||||
/// <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('/', '_');
|
||||
}
|
||||
Reference in New Issue
Block a user