| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- using System.Security.Cryptography;
- using System.Text;
- using Org.BouncyCastle.Crypto.Engines;
- using Org.BouncyCastle.Crypto.Modes;
- using Org.BouncyCastle.Crypto.Parameters;
- namespace Infrastructure.Kyc.Nice;
- /// <summary>
- /// NICE 통합인증 API 의 암호화/무결성 검증 헬퍼.
- ///
- /// 가이드: .claude/plan/nice-auth-integration.md §3 (암호화/무결성 사양)
- ///
- /// 핵심 사양 (NICE PHP/Node.js 공식 sample 과 1:1 호환):
- /// - PBKDF2WithHmacSHA256, salt = transactionId UTF-8 byte, iterations = iterators, output 64 byte
- /// - keyString = URL-safe Base64 (no padding) of 64 byte → 항상 86 char
- /// - AES-256 key = keyString.Substring(0, 32) 의 ASCII byte (32 byte)
- /// - HMAC-SHA256 key = keyString.Substring(48, 32) 의 ASCII byte (32 byte)
- /// - AES/GCM/NoPadding (256bit key, 128bit tag), IV = enc_data 의 앞 16 byte
- /// - integrity_value = Base64Url(HMAC-SHA256(hmacKey, UTF8(enc_data)))
- /// </summary>
- internal static class NiceCrypto
- {
- private const int Pbkdf2OutputBytes = 64;
- private const int AesKeyLength = 32;
- private const int HmacKeyLength = 32;
- private const int HmacKeyOffsetInKeyString = 48;
- private const int IvSize = 16;
- private const int GcmTagSize = 16;
- /// <summary>
- /// URL-safe Base64 인코딩 (no padding). RFC 4648 §5. `+`→`-`, `/`→`_`, 끝의 `=` 제거.
- /// </summary>
- public static string Base64UrlEncode(byte[] bytes)
- {
- ArgumentNullException.ThrowIfNull(bytes);
- return Convert.ToBase64String(bytes)
- .Replace('+', '-')
- .Replace('/', '_')
- .TrimEnd('=');
- }
- /// <summary>
- /// URL-safe Base64 디코딩 (padding 자동 복원).
- /// </summary>
- public static byte[] Base64UrlDecode(string s)
- {
- ArgumentException.ThrowIfNullOrEmpty(s);
- var padded = s.Replace('-', '+').Replace('_', '/');
- switch (padded.Length % 4)
- {
- case 2:
- padded += "==";
- break;
- case 3:
- padded += "=";
- break;
- case 1:
- throw new FormatException("Base64Url 문자열 길이가 유효하지 않습니다.");
- }
- return Convert.FromBase64String(padded);
- }
- /// <summary>
- /// PBKDF2 키 유도 후 AES-256 / HMAC-SHA256 키 분할.
- /// NICE PHP / Node.js 공식 sample 과 1:1 호환.
- /// </summary>
- public static (byte[] AesKey, byte[] HmacKey) DeriveKeys(string ticket, string transactionId, int iterators)
- {
- ArgumentException.ThrowIfNullOrEmpty(ticket);
- ArgumentException.ThrowIfNullOrEmpty(transactionId);
- if (iterators <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(iterators), iterators, "iterators 는 양의 정수여야 합니다.");
- }
- var salt = Encoding.UTF8.GetBytes(transactionId);
- using var pbkdf2 = new Rfc2898DeriveBytes(ticket, salt, iterators, HashAlgorithmName.SHA256);
- var keyBytes = pbkdf2.GetBytes(Pbkdf2OutputBytes);
- var keyString = Base64UrlEncode(keyBytes); // 항상 86 char (64 byte → no-padding URL-safe Base64)
- if (keyString.Length < HmacKeyOffsetInKeyString + HmacKeyLength)
- {
- throw new CryptographicException($"NICE keyString 길이가 비정상: {keyString.Length} (PBKDF2 출력 손상 의심).");
- }
- var aesKey = Encoding.ASCII.GetBytes(keyString.Substring(0, AesKeyLength));
- var hmacKey = Encoding.ASCII.GetBytes(keyString.Substring(HmacKeyOffsetInKeyString, HmacKeyLength));
- return (aesKey, hmacKey);
- }
- /// <summary>
- /// HMAC-SHA256 무결성 검증 (timing-safe).
- /// </summary>
- public static bool VerifyIntegrity(byte[] hmacKey, string encData, string integrityValue)
- {
- ArgumentNullException.ThrowIfNull(hmacKey);
- ArgumentException.ThrowIfNullOrEmpty(encData);
- ArgumentException.ThrowIfNullOrEmpty(integrityValue);
- var calculated = ComputeIntegrity(hmacKey, encData);
- return CryptographicOperations.FixedTimeEquals(
- Encoding.ASCII.GetBytes(calculated),
- Encoding.ASCII.GetBytes(integrityValue));
- }
- /// <summary>
- /// HMAC-SHA256(hmacKey, UTF8(encData)) → URL-safe Base64 (no padding).
- /// 테스트/디버깅 보조용. 운영 검증은 <see cref="VerifyIntegrity"/> 사용 (timing-safe).
- /// </summary>
- public static string ComputeIntegrity(byte[] hmacKey, string encData)
- {
- ArgumentNullException.ThrowIfNull(hmacKey);
- ArgumentException.ThrowIfNullOrEmpty(encData);
- using var hmac = new HMACSHA256(hmacKey);
- var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(encData));
- return Base64UrlEncode(hash);
- }
- /// <summary>
- /// AES-256-GCM 복호화. enc_data 레이아웃: [ IV(16) | ciphertext+tag(N-16) ]
- /// (BouncyCastle 의 GcmBlockCipher 가 ciphertext+tag 를 합쳐서 받고 내부에서 tag 분리)
- ///
- /// .NET 표준 <see cref="System.Security.Cryptography.AesGcm"/> 은 12 byte nonce 만 지원해서
- /// NICE 의 16 byte IV 와 호환 불가 → BouncyCastle 의 GcmBlockCipher 사용.
- /// </summary>
- public static string DecryptAesGcm(byte[] aesKey, string encData)
- {
- ArgumentNullException.ThrowIfNull(aesKey);
- ArgumentException.ThrowIfNullOrEmpty(encData);
- if (aesKey.Length != AesKeyLength)
- {
- throw new ArgumentException($"AES-256 키 길이는 {AesKeyLength} byte 여야 합니다. (현재: {aesKey.Length})", nameof(aesKey));
- }
- var cipherEnc = Base64UrlDecode(encData);
- var minLength = IvSize + GcmTagSize;
- if (cipherEnc.Length < minLength)
- {
- throw new CryptographicException($"enc_data 길이가 {minLength} byte 미만입니다.");
- }
- var iv = new byte[IvSize];
- Buffer.BlockCopy(cipherEnc, 0, iv, 0, IvSize);
- var ctTagLen = cipherEnc.Length - IvSize;
- var ctWithTag = new byte[ctTagLen];
- Buffer.BlockCopy(cipherEnc, IvSize, ctWithTag, 0, ctTagLen);
- var cipher = new GcmBlockCipher(new AesEngine());
- cipher.Init(false, new AeadParameters(new KeyParameter(aesKey), GcmTagSize * 8, iv));
- var plaintext = new byte[cipher.GetOutputSize(ctTagLen)];
- var len = cipher.ProcessBytes(ctWithTag, 0, ctTagLen, plaintext, 0);
- len += cipher.DoFinal(plaintext, len);
- return Encoding.UTF8.GetString(plaintext, 0, len);
- }
- /// <summary>
- /// AES-256-GCM 암호화 (NICE 와 동일 레이아웃 — invariants self-check 용).
- /// 운영에서는 NICE 가 enc_data 발급하므로 호출 안 함.
- /// </summary>
- internal static string EncryptAesGcm(byte[] aesKey, string plaintext)
- {
- ArgumentNullException.ThrowIfNull(aesKey);
- ArgumentException.ThrowIfNullOrEmpty(plaintext);
- if (aesKey.Length != AesKeyLength)
- {
- throw new ArgumentException($"AES-256 키 길이는 {AesKeyLength} byte 여야 합니다.", nameof(aesKey));
- }
- var iv = new byte[IvSize];
- RandomNumberGenerator.Fill(iv);
- var plainBytes = Encoding.UTF8.GetBytes(plaintext);
- var cipher = new GcmBlockCipher(new AesEngine());
- cipher.Init(true, new AeadParameters(new KeyParameter(aesKey), GcmTagSize * 8, iv));
- var output = new byte[cipher.GetOutputSize(plainBytes.Length)];
- var len = cipher.ProcessBytes(plainBytes, 0, plainBytes.Length, output, 0);
- len += cipher.DoFinal(output, len);
- // NICE 레이아웃: [IV | ciphertext+tag]
- var combined = new byte[IvSize + len];
- Buffer.BlockCopy(iv, 0, combined, 0, IvSize);
- Buffer.BlockCopy(output, 0, combined, IvSize, len);
- return Base64UrlEncode(combined);
- }
- }
|