NiceCrypto.cs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. using System.Security.Cryptography;
  2. using System.Text;
  3. using Org.BouncyCastle.Crypto.Engines;
  4. using Org.BouncyCastle.Crypto.Modes;
  5. using Org.BouncyCastle.Crypto.Parameters;
  6. namespace Infrastructure.Kyc.Nice;
  7. /// <summary>
  8. /// NICE 통합인증 API 의 암호화/무결성 검증 헬퍼.
  9. ///
  10. /// 가이드: .claude/plan/nice-auth-integration.md §3 (암호화/무결성 사양)
  11. ///
  12. /// 핵심 사양 (NICE PHP/Node.js 공식 sample 과 1:1 호환):
  13. /// - PBKDF2WithHmacSHA256, salt = transactionId UTF-8 byte, iterations = iterators, output 64 byte
  14. /// - keyString = URL-safe Base64 (no padding) of 64 byte → 항상 86 char
  15. /// - AES-256 key = keyString.Substring(0, 32) 의 ASCII byte (32 byte)
  16. /// - HMAC-SHA256 key = keyString.Substring(48, 32) 의 ASCII byte (32 byte)
  17. /// - AES/GCM/NoPadding (256bit key, 128bit tag), IV = enc_data 의 앞 16 byte
  18. /// - integrity_value = Base64Url(HMAC-SHA256(hmacKey, UTF8(enc_data)))
  19. /// </summary>
  20. internal static class NiceCrypto
  21. {
  22. private const int Pbkdf2OutputBytes = 64;
  23. private const int AesKeyLength = 32;
  24. private const int HmacKeyLength = 32;
  25. private const int HmacKeyOffsetInKeyString = 48;
  26. private const int IvSize = 16;
  27. private const int GcmTagSize = 16;
  28. /// <summary>
  29. /// URL-safe Base64 인코딩 (no padding). RFC 4648 §5. `+`→`-`, `/`→`_`, 끝의 `=` 제거.
  30. /// </summary>
  31. public static string Base64UrlEncode(byte[] bytes)
  32. {
  33. ArgumentNullException.ThrowIfNull(bytes);
  34. return Convert.ToBase64String(bytes)
  35. .Replace('+', '-')
  36. .Replace('/', '_')
  37. .TrimEnd('=');
  38. }
  39. /// <summary>
  40. /// URL-safe Base64 디코딩 (padding 자동 복원).
  41. /// </summary>
  42. public static byte[] Base64UrlDecode(string s)
  43. {
  44. ArgumentException.ThrowIfNullOrEmpty(s);
  45. var padded = s.Replace('-', '+').Replace('_', '/');
  46. switch (padded.Length % 4)
  47. {
  48. case 2:
  49. padded += "==";
  50. break;
  51. case 3:
  52. padded += "=";
  53. break;
  54. case 1:
  55. throw new FormatException("Base64Url 문자열 길이가 유효하지 않습니다.");
  56. }
  57. return Convert.FromBase64String(padded);
  58. }
  59. /// <summary>
  60. /// PBKDF2 키 유도 후 AES-256 / HMAC-SHA256 키 분할.
  61. /// NICE PHP / Node.js 공식 sample 과 1:1 호환.
  62. /// </summary>
  63. public static (byte[] AesKey, byte[] HmacKey) DeriveKeys(string ticket, string transactionId, int iterators)
  64. {
  65. ArgumentException.ThrowIfNullOrEmpty(ticket);
  66. ArgumentException.ThrowIfNullOrEmpty(transactionId);
  67. if (iterators <= 0)
  68. {
  69. throw new ArgumentOutOfRangeException(nameof(iterators), iterators, "iterators 는 양의 정수여야 합니다.");
  70. }
  71. var salt = Encoding.UTF8.GetBytes(transactionId);
  72. using var pbkdf2 = new Rfc2898DeriveBytes(ticket, salt, iterators, HashAlgorithmName.SHA256);
  73. var keyBytes = pbkdf2.GetBytes(Pbkdf2OutputBytes);
  74. var keyString = Base64UrlEncode(keyBytes); // 항상 86 char (64 byte → no-padding URL-safe Base64)
  75. if (keyString.Length < HmacKeyOffsetInKeyString + HmacKeyLength)
  76. {
  77. throw new CryptographicException($"NICE keyString 길이가 비정상: {keyString.Length} (PBKDF2 출력 손상 의심).");
  78. }
  79. var aesKey = Encoding.ASCII.GetBytes(keyString.Substring(0, AesKeyLength));
  80. var hmacKey = Encoding.ASCII.GetBytes(keyString.Substring(HmacKeyOffsetInKeyString, HmacKeyLength));
  81. return (aesKey, hmacKey);
  82. }
  83. /// <summary>
  84. /// HMAC-SHA256 무결성 검증 (timing-safe).
  85. /// </summary>
  86. public static bool VerifyIntegrity(byte[] hmacKey, string encData, string integrityValue)
  87. {
  88. ArgumentNullException.ThrowIfNull(hmacKey);
  89. ArgumentException.ThrowIfNullOrEmpty(encData);
  90. ArgumentException.ThrowIfNullOrEmpty(integrityValue);
  91. var calculated = ComputeIntegrity(hmacKey, encData);
  92. return CryptographicOperations.FixedTimeEquals(
  93. Encoding.ASCII.GetBytes(calculated),
  94. Encoding.ASCII.GetBytes(integrityValue));
  95. }
  96. /// <summary>
  97. /// HMAC-SHA256(hmacKey, UTF8(encData)) → URL-safe Base64 (no padding).
  98. /// 테스트/디버깅 보조용. 운영 검증은 <see cref="VerifyIntegrity"/> 사용 (timing-safe).
  99. /// </summary>
  100. public static string ComputeIntegrity(byte[] hmacKey, string encData)
  101. {
  102. ArgumentNullException.ThrowIfNull(hmacKey);
  103. ArgumentException.ThrowIfNullOrEmpty(encData);
  104. using var hmac = new HMACSHA256(hmacKey);
  105. var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(encData));
  106. return Base64UrlEncode(hash);
  107. }
  108. /// <summary>
  109. /// AES-256-GCM 복호화. enc_data 레이아웃: [ IV(16) | ciphertext+tag(N-16) ]
  110. /// (BouncyCastle 의 GcmBlockCipher 가 ciphertext+tag 를 합쳐서 받고 내부에서 tag 분리)
  111. ///
  112. /// .NET 표준 <see cref="System.Security.Cryptography.AesGcm"/> 은 12 byte nonce 만 지원해서
  113. /// NICE 의 16 byte IV 와 호환 불가 → BouncyCastle 의 GcmBlockCipher 사용.
  114. /// </summary>
  115. public static string DecryptAesGcm(byte[] aesKey, string encData)
  116. {
  117. ArgumentNullException.ThrowIfNull(aesKey);
  118. ArgumentException.ThrowIfNullOrEmpty(encData);
  119. if (aesKey.Length != AesKeyLength)
  120. {
  121. throw new ArgumentException($"AES-256 키 길이는 {AesKeyLength} byte 여야 합니다. (현재: {aesKey.Length})", nameof(aesKey));
  122. }
  123. var cipherEnc = Base64UrlDecode(encData);
  124. var minLength = IvSize + GcmTagSize;
  125. if (cipherEnc.Length < minLength)
  126. {
  127. throw new CryptographicException($"enc_data 길이가 {minLength} byte 미만입니다.");
  128. }
  129. var iv = new byte[IvSize];
  130. Buffer.BlockCopy(cipherEnc, 0, iv, 0, IvSize);
  131. var ctTagLen = cipherEnc.Length - IvSize;
  132. var ctWithTag = new byte[ctTagLen];
  133. Buffer.BlockCopy(cipherEnc, IvSize, ctWithTag, 0, ctTagLen);
  134. var cipher = new GcmBlockCipher(new AesEngine());
  135. cipher.Init(false, new AeadParameters(new KeyParameter(aesKey), GcmTagSize * 8, iv));
  136. var plaintext = new byte[cipher.GetOutputSize(ctTagLen)];
  137. var len = cipher.ProcessBytes(ctWithTag, 0, ctTagLen, plaintext, 0);
  138. len += cipher.DoFinal(plaintext, len);
  139. return Encoding.UTF8.GetString(plaintext, 0, len);
  140. }
  141. /// <summary>
  142. /// AES-256-GCM 암호화 (NICE 와 동일 레이아웃 — invariants self-check 용).
  143. /// 운영에서는 NICE 가 enc_data 발급하므로 호출 안 함.
  144. /// </summary>
  145. internal static string EncryptAesGcm(byte[] aesKey, string plaintext)
  146. {
  147. ArgumentNullException.ThrowIfNull(aesKey);
  148. ArgumentException.ThrowIfNullOrEmpty(plaintext);
  149. if (aesKey.Length != AesKeyLength)
  150. {
  151. throw new ArgumentException($"AES-256 키 길이는 {AesKeyLength} byte 여야 합니다.", nameof(aesKey));
  152. }
  153. var iv = new byte[IvSize];
  154. RandomNumberGenerator.Fill(iv);
  155. var plainBytes = Encoding.UTF8.GetBytes(plaintext);
  156. var cipher = new GcmBlockCipher(new AesEngine());
  157. cipher.Init(true, new AeadParameters(new KeyParameter(aesKey), GcmTagSize * 8, iv));
  158. var output = new byte[cipher.GetOutputSize(plainBytes.Length)];
  159. var len = cipher.ProcessBytes(plainBytes, 0, plainBytes.Length, output, 0);
  160. len += cipher.DoFinal(output, len);
  161. // NICE 레이아웃: [IV | ciphertext+tag]
  162. var combined = new byte[IvSize + len];
  163. Buffer.BlockCopy(iv, 0, combined, 0, IvSize);
  164. Buffer.BlockCopy(output, 0, combined, IvSize, len);
  165. return Base64UrlEncode(combined);
  166. }
  167. }