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);
}
}