| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142 |
- using System.Security.Cryptography;
- using System.Text;
- using Application.Abstractions.Crypto;
- using Microsoft.Extensions.Options;
- using SharedKernel;
- namespace Infrastructure.Crypto;
- /// <summary>
- /// AES-256-GCM 기반 필드 단위 암호화기. KeyVersion 별 키 묶음 지원.
- ///
- /// 출력 포맷 (Base64):
- /// | 12 bytes | N bytes | 16 bytes |
- /// | nonce | ciphertext | tag |
- ///
- /// nonce 는 매 암호화마다 RandomNumberGenerator 로 생성. 동일 평문이라도 매번 다른 암호문 반환 (probabilistic).
- /// 검색이 필요한 컬럼은 별도 SHA-256 해시 컬럼(Hash)을 함께 저장.
- /// </summary>
- 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<int, byte[]> _keys;
- public int CurrentKeyVersion { get; }
- public AesGcmFieldEncryptor(IOptions<AppSettings> 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<int, byte[]>(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);
- }
- }
|