using System.Security.Cryptography;
using System.Text;
using Application.Abstractions.Crypto;
using Microsoft.Extensions.Options;
using SharedKernel;
namespace Infrastructure.Crypto;
///
/// AES-256-GCM 기반 필드 단위 암호화기. KeyVersion 별 키 묶음 지원.
///
/// 출력 포맷 (Base64):
/// | 12 bytes | N bytes | 16 bytes |
/// | nonce | ciphertext | tag |
///
/// nonce 는 매 암호화마다 RandomNumberGenerator 로 생성. 동일 평문이라도 매번 다른 암호문 반환 (probabilistic).
/// 검색이 필요한 컬럼은 별도 SHA-256 해시 컬럼(Hash)을 함께 저장.
///
public sealed class AesGcmFieldEncryptor : IFieldEncryptor
{
private const int NonceSize = 12;
private const int TagSize = 16;
private const int KeySize = 32; // AES-256
private readonly Dictionary _keys;
public int CurrentKeyVersion { get; }
public AesGcmFieldEncryptor(IOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var section = options.Value.Encryption;
if (section is null || section.Keys is null || section.Keys.Count == 0)
{
throw new InvalidOperationException("Encryption.Keys 가 설정되지 않았습니다. appsettings 에 V1 키 (Base64 32바이트) 를 추가하세요.");
}
_keys = new Dictionary(section.Keys.Count);
foreach (var kvp in section.Keys)
{
// 키 이름 형식: "V1", "V2", ... -> 정수 추출
if (!kvp.Key.StartsWith('V') || !int.TryParse(kvp.Key.AsSpan(1), out var version))
{
throw new InvalidOperationException($"Encryption.Keys 의 키 이름은 'V{{int}}' 형식이어야 합니다. 현재: '{kvp.Key}'");
}
byte[] keyBytes;
try
{
keyBytes = Convert.FromBase64String(kvp.Value);
}
catch (FormatException ex)
{
throw new InvalidOperationException($"Encryption.Keys.{kvp.Key} 값이 유효한 Base64 가 아닙니다.", ex);
}
if (keyBytes.Length != KeySize)
{
throw new InvalidOperationException($"Encryption.Keys.{kvp.Key} 키 길이가 {KeySize} 바이트가 아닙니다. AES-256 키만 허용.");
}
_keys[version] = keyBytes;
}
CurrentKeyVersion = section.CurrentKeyVersion;
if (!_keys.ContainsKey(CurrentKeyVersion))
{
throw new InvalidOperationException($"Encryption.CurrentKeyVersion={CurrentKeyVersion} 에 해당하는 키가 Encryption.Keys 에 없습니다.");
}
}
public string Encrypt(string plaintext)
{
ArgumentNullException.ThrowIfNull(plaintext);
var key = _keys[CurrentKeyVersion];
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var nonce = new byte[NonceSize];
RandomNumberGenerator.Fill(nonce);
var cipher = new byte[plainBytes.Length];
var tag = new byte[TagSize];
using var aes = new AesGcm(key, TagSize);
aes.Encrypt(nonce, plainBytes, cipher, tag);
// [nonce(12) | cipher(N) | tag(16)] 결합 -> Base64
var combined = new byte[NonceSize + cipher.Length + TagSize];
Buffer.BlockCopy(nonce, 0, combined, 0, NonceSize);
Buffer.BlockCopy(cipher, 0, combined, NonceSize, cipher.Length);
Buffer.BlockCopy(tag, 0, combined, NonceSize + cipher.Length, TagSize);
return Convert.ToBase64String(combined);
}
public string Decrypt(string ciphertextBase64, int keyVersion)
{
ArgumentException.ThrowIfNullOrWhiteSpace(ciphertextBase64);
if (!_keys.TryGetValue(keyVersion, out var key))
{
throw new InvalidOperationException($"KeyVersion={keyVersion} 에 해당하는 키가 없습니다. (키 회전 시 과거 키도 Encryption.Keys 에 유지 필요)");
}
var combined = Convert.FromBase64String(ciphertextBase64);
if (combined.Length < NonceSize + TagSize)
{
throw new CryptographicException("암호문 길이가 너무 짧습니다.");
}
var nonce = new byte[NonceSize];
var tag = new byte[TagSize];
var cipher = new byte[combined.Length - NonceSize - TagSize];
Buffer.BlockCopy(combined, 0, nonce, 0, NonceSize);
Buffer.BlockCopy(combined, NonceSize, cipher, 0, cipher.Length);
Buffer.BlockCopy(combined, NonceSize + cipher.Length, tag, 0, TagSize);
var plain = new byte[cipher.Length];
using var aes = new AesGcm(key, TagSize);
aes.Decrypt(nonce, cipher, tag, plain);
return Encoding.UTF8.GetString(plain);
}
public string Hash(string plaintext)
{
ArgumentNullException.ThrowIfNull(plaintext);
var bytes = Encoding.UTF8.GetBytes(plaintext);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash);
}
}