AesGcmFieldEncryptor.cs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. using System.Security.Cryptography;
  2. using System.Text;
  3. using Application.Abstractions.Crypto;
  4. using Microsoft.Extensions.Options;
  5. using SharedKernel;
  6. namespace Infrastructure.Crypto;
  7. /// <summary>
  8. /// AES-256-GCM 기반 필드 단위 암호화기. KeyVersion 별 키 묶음 지원.
  9. ///
  10. /// 출력 포맷 (Base64):
  11. /// | 12 bytes | N bytes | 16 bytes |
  12. /// | nonce | ciphertext | tag |
  13. ///
  14. /// nonce 는 매 암호화마다 RandomNumberGenerator 로 생성. 동일 평문이라도 매번 다른 암호문 반환 (probabilistic).
  15. /// 검색이 필요한 컬럼은 별도 SHA-256 해시 컬럼(Hash)을 함께 저장.
  16. /// </summary>
  17. public sealed class AesGcmFieldEncryptor : IFieldEncryptor
  18. {
  19. private const int NonceSize = 12;
  20. private const int TagSize = 16;
  21. private const int KeySize = 32; // AES-256
  22. private readonly Dictionary<int, byte[]> _keys;
  23. public int CurrentKeyVersion { get; }
  24. public AesGcmFieldEncryptor(IOptions<AppSettings> options)
  25. {
  26. ArgumentNullException.ThrowIfNull(options);
  27. var section = options.Value.Encryption;
  28. if (section is null || section.Keys is null || section.Keys.Count == 0)
  29. {
  30. throw new InvalidOperationException("Encryption.Keys 가 설정되지 않았습니다. appsettings 에 V1 키 (Base64 32바이트) 를 추가하세요.");
  31. }
  32. _keys = new Dictionary<int, byte[]>(section.Keys.Count);
  33. foreach (var kvp in section.Keys)
  34. {
  35. // 키 이름 형식: "V1", "V2", ... -> 정수 추출
  36. if (!kvp.Key.StartsWith('V') || !int.TryParse(kvp.Key.AsSpan(1), out var version))
  37. {
  38. throw new InvalidOperationException($"Encryption.Keys 의 키 이름은 'V{{int}}' 형식이어야 합니다. 현재: '{kvp.Key}'");
  39. }
  40. byte[] keyBytes;
  41. try
  42. {
  43. keyBytes = Convert.FromBase64String(kvp.Value);
  44. }
  45. catch (FormatException ex)
  46. {
  47. throw new InvalidOperationException($"Encryption.Keys.{kvp.Key} 값이 유효한 Base64 가 아닙니다.", ex);
  48. }
  49. if (keyBytes.Length != KeySize)
  50. {
  51. throw new InvalidOperationException($"Encryption.Keys.{kvp.Key} 키 길이가 {KeySize} 바이트가 아닙니다. AES-256 키만 허용.");
  52. }
  53. _keys[version] = keyBytes;
  54. }
  55. CurrentKeyVersion = section.CurrentKeyVersion;
  56. if (!_keys.ContainsKey(CurrentKeyVersion))
  57. {
  58. throw new InvalidOperationException($"Encryption.CurrentKeyVersion={CurrentKeyVersion} 에 해당하는 키가 Encryption.Keys 에 없습니다.");
  59. }
  60. }
  61. public string Encrypt(string plaintext)
  62. {
  63. ArgumentNullException.ThrowIfNull(plaintext);
  64. var key = _keys[CurrentKeyVersion];
  65. var plainBytes = Encoding.UTF8.GetBytes(plaintext);
  66. var nonce = new byte[NonceSize];
  67. RandomNumberGenerator.Fill(nonce);
  68. var cipher = new byte[plainBytes.Length];
  69. var tag = new byte[TagSize];
  70. using var aes = new AesGcm(key, TagSize);
  71. aes.Encrypt(nonce, plainBytes, cipher, tag);
  72. // [nonce(12) | cipher(N) | tag(16)] 결합 -> Base64
  73. var combined = new byte[NonceSize + cipher.Length + TagSize];
  74. Buffer.BlockCopy(nonce, 0, combined, 0, NonceSize);
  75. Buffer.BlockCopy(cipher, 0, combined, NonceSize, cipher.Length);
  76. Buffer.BlockCopy(tag, 0, combined, NonceSize + cipher.Length, TagSize);
  77. return Convert.ToBase64String(combined);
  78. }
  79. public string Decrypt(string ciphertextBase64, int keyVersion)
  80. {
  81. ArgumentException.ThrowIfNullOrWhiteSpace(ciphertextBase64);
  82. if (!_keys.TryGetValue(keyVersion, out var key))
  83. {
  84. throw new InvalidOperationException($"KeyVersion={keyVersion} 에 해당하는 키가 없습니다. (키 회전 시 과거 키도 Encryption.Keys 에 유지 필요)");
  85. }
  86. var combined = Convert.FromBase64String(ciphertextBase64);
  87. if (combined.Length < NonceSize + TagSize)
  88. {
  89. throw new CryptographicException("암호문 길이가 너무 짧습니다.");
  90. }
  91. var nonce = new byte[NonceSize];
  92. var tag = new byte[TagSize];
  93. var cipher = new byte[combined.Length - NonceSize - TagSize];
  94. Buffer.BlockCopy(combined, 0, nonce, 0, NonceSize);
  95. Buffer.BlockCopy(combined, NonceSize, cipher, 0, cipher.Length);
  96. Buffer.BlockCopy(combined, NonceSize + cipher.Length, tag, 0, TagSize);
  97. var plain = new byte[cipher.Length];
  98. using var aes = new AesGcm(key, TagSize);
  99. aes.Decrypt(nonce, cipher, tag, plain);
  100. return Encoding.UTF8.GetString(plain);
  101. }
  102. public string Hash(string plaintext)
  103. {
  104. ArgumentNullException.ThrowIfNull(plaintext);
  105. var bytes = Encoding.UTF8.GetBytes(plaintext);
  106. var hash = SHA256.HashData(bytes);
  107. return Convert.ToHexString(hash);
  108. }
  109. }