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); } }