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; /// /// 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))) /// 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; /// /// URL-safe Base64 인코딩 (no padding). RFC 4648 §5. `+`→`-`, `/`→`_`, 끝의 `=` 제거. /// public static string Base64UrlEncode(byte[] bytes) { ArgumentNullException.ThrowIfNull(bytes); return Convert.ToBase64String(bytes) .Replace('+', '-') .Replace('/', '_') .TrimEnd('='); } /// /// URL-safe Base64 디코딩 (padding 자동 복원). /// 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); } /// /// PBKDF2 키 유도 후 AES-256 / HMAC-SHA256 키 분할. /// NICE PHP / Node.js 공식 sample 과 1:1 호환. /// 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); } /// /// HMAC-SHA256 무결성 검증 (timing-safe). /// 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)); } /// /// HMAC-SHA256(hmacKey, UTF8(encData)) → URL-safe Base64 (no padding). /// 테스트/디버깅 보조용. 운영 검증은 사용 (timing-safe). /// 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); } /// /// AES-256-GCM 복호화. enc_data 레이아웃: [ IV(16) | ciphertext+tag(N-16) ] /// (BouncyCastle 의 GcmBlockCipher 가 ciphertext+tag 를 합쳐서 받고 내부에서 tag 분리) /// /// .NET 표준 은 12 byte nonce 만 지원해서 /// NICE 의 16 byte IV 와 호환 불가 → BouncyCastle 의 GcmBlockCipher 사용. /// 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); } /// /// AES-256-GCM 암호화 (NICE 와 동일 레이아웃 — invariants self-check 용). /// 운영에서는 NICE 가 enc_data 발급하므로 호출 안 함. /// 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); } }