package com.eactive.eai.common.hsm; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.security.KeyStore; import java.security.PrivateKey; import java.security.Provider; import java.security.PublicKey; import java.security.cert.Certificate; import java.util.concurrent.ConcurrentHashMap; import javax.annotation.PostConstruct; import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.eactive.eai.common.property.PropManager; import com.eactive.eai.common.util.Logger; /** * SafeNet ProtectServer HSM 암복호화 서비스 (JDK 8) * * [키 조회] * getPublicKey - 공개키 반환 (외부 노출). 최초 1회만 HSM 통신, 이후 캐시 반환. * getSecretKey - AES 대칭키 반환 (CKA_EXTRACTABLE=true 필요). 최초 1회만 HSM 통신, 이후 캐시 반환. * * [암복호화] * encryptRsa - 공개키로 JVM 소프트웨어 암호화 (HSM 불필요) * decryptRsa - alias 기반, HSM 내부 복호화 (매 호출마다 HSM 통신 발생 - 불가피) * encryptAes - SecretKey 객체로 AES/CBC 암호화 * decryptAes - SecretKey 객체로 AES/CBC 복호화 */ @Service public class HsmCryptoService implements PropertyChangeListener { static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT); private static final String RSA_ALGORITHM = "RSA/ECB/PKCS1Padding"; private static final String AES_ALGORITHM = "AES/CBC/PKCS5Padding"; private static final String PROP_GROUP = "HSM"; private static final String PROP_CACHE_RELOAD_YN = "CACHE_RELOAD_YN"; private static final String PROP_CACHE_TTL_SEC = "CACHE_TTL_SEC"; private static long CACHE_TTL_MS = 10 * 60 * 1000; // 10분, 필요시 PropManager로 외부화 private static class CachedKey { final T key; final long cachedAt; CachedKey(T key) { this.key = key; this.cachedAt = System.currentTimeMillis(); } boolean isExpired() { return System.currentTimeMillis() - cachedAt > CACHE_TTL_MS; } } // HSM 통신 최소화: 최초 1회 조회 후 JVM 메모리에 캐싱 private final ConcurrentHashMap> secretKeyCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> publicKeyCache = new ConcurrentHashMap<>(); @Autowired private PropManager propManager; @PostConstruct public void registerPropertyChangeListener() { propManager.addPropertyChangeListener(this); logger.warn("HsmCryptoService] PropManager PropertyChangeListener 등록 완료"); } /** * 관리포털에서 PropManager.reload("HSM") 호출 시 이벤트를 수신한다. * HSM.CACHE_RELOAD_YN = Y 이면 키 캐시를 초기화한다. */ @Override public void propertyChange(PropertyChangeEvent evt) { if (!PROP_GROUP.equals(evt.getPropertyName())) { return; } String reloadYn = propManager.getProperty(PROP_GROUP, PROP_CACHE_RELOAD_YN, "N"); if ("Y".equalsIgnoreCase(reloadYn)) { clearKeyCache(); } String propCacheTtlSec = propManager.getProperty(PROP_GROUP, PROP_CACHE_TTL_SEC, "600"); CACHE_TTL_MS = (Integer.parseInt(propCacheTtlSec.trim())) * 1000; // 10분, 필요시 PropManager로 외부화 } // ------------------------------------------------------------------------- // 키 조회 (외부 노출) // ------------------------------------------------------------------------- /** * HSM KeyStore 에서 공개키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다. * HSM 장애 시 만료된 캐시가 있으면 그것을 반환하여 서비스 연속성을 유지합니다. */ public PublicKey getPublicKey(String keyAlias) throws HsmException { // 1. 유효한 캐시 즉시 반환 (HSM 상태 무관) CachedKey cached = publicKeyCache.get(keyAlias); if (cached != null && !cached.isExpired()) { return cached.key; } // 2. 캐시 미스 또는 만료 → HSM 갱신 시도 if (HsmManager.getInstance().isReady()) { try { Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias); if (cert == null) { throw new HsmException("인증서를 찾을 수 없습니다. alias=" + keyAlias); } PublicKey key = cert.getPublicKey(); publicKeyCache.put(keyAlias, new CachedKey<>(key)); logger.warn("HsmCryptoService] 공개키 캐시 등록: alias=" + keyAlias); return key; } catch (HsmException e) { throw e; } catch (Exception e) { logger.warn("HsmCryptoService] 공개키 HSM 조회 실패: " + e.getMessage()); } } // 3. HSM 조회 불가 → 만료 캐시 fallback if (cached != null) { logger.warn("HsmCryptoService] HSM 장애, 만료 공개키 캐시 fallback: alias=" + keyAlias); return cached.key; } throw new HsmException("공개키 조회 불가: HSM 장애이며 캐시도 없습니다. alias=" + keyAlias); } /** * HSM KeyStore 에서 AES 대칭키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다. * HSM 키 생성 시 CKA_EXTRACTABLE=true (ctkmu -x 플래그) 로 생성된 키만 반환됩니다. * * [캐싱 전략] * HSM 에서 가져온 P11SecretKey(PKCS11 핸들 래퍼)를 그대로 캐싱하면, HSM Provider 가 * 재초기화될 때 세션 무효화로 인해 캐시된 키를 사용한 Cipher 연산이 실패한다. * 따라서 getEncoded() 로 키 바이트를 추출하여 SecretKeySpec(JVM 메모리 키) 으로 변환 후 * 캐싱한다. encryptAes/decryptAes 는 이미 JVM 소프트웨어 Cipher 를 사용하므로, * HSM Provider 상태와 완전히 독립적으로 동작한다. * * HSM 장애 시 만료된 캐시가 있으면 그것을 반환하여 서비스 연속성을 유지합니다. */ public SecretKey getSecretKey(String keyAlias) throws HsmException { // 1. 유효한 캐시 즉시 반환 (HSM 상태 무관) CachedKey cached = secretKeyCache.get(keyAlias); if (cached != null && !cached.isExpired()) { return cached.key; } // 2. 캐시 미스 또는 만료 → HSM 갱신 시도 if (HsmManager.getInstance().isReady()) { try { KeyStore keyStore = HsmManager.getInstance().getKeyStore(); java.security.Key key = keyStore.getKey(keyAlias, null); if (key == null) { throw new HsmException("키를 찾을 수 없습니다. alias=" + keyAlias); } SecretKey secretKey = (SecretKey) key; // P11SecretKey → SecretKeySpec 변환: HSM Provider 의존성 제거 // getEncoded() 가 null 이면 non-extractable 키이므로 원본 유지 byte[] keyBytes = secretKey.getEncoded(); if (keyBytes != null) { secretKey = new SecretKeySpec(keyBytes, secretKey.getAlgorithm()); } secretKeyCache.put(keyAlias, new CachedKey<>(secretKey)); logger.warn("HsmCryptoService] 대칭키 캐시 등록(갱신): alias=" + keyAlias); return secretKey; } catch (HsmException e) { throw e; } catch (Exception e) { logger.warn("HsmCryptoService] 대칭키 HSM 조회 실패: " + e.getMessage()); } } // 3. HSM 조회 불가 → 만료 캐시 fallback if (cached != null) { logger.warn("HsmCryptoService] HSM 장애, 만료 대칭키 캐시 fallback: alias=" + keyAlias); return cached.key; } throw new HsmException("키 조회 불가: HSM 장애이며 캐시도 없습니다. alias=" + keyAlias); } /** 캐시를 비웁니다. HSM 키 교체 후 재로드가 필요할 때 호출합니다. */ public void clearKeyCache() { secretKeyCache.clear(); publicKeyCache.clear(); logger.warn("HsmCryptoService] 키 캐시 초기화 완료"); } // ------------------------------------------------------------------------- // RSA // ------------------------------------------------------------------------- /** * RSA 암호화 - HSM 인증서의 공개키로 JVM 소프트웨어 암호화. * 공개키는 HSM 에서 추출하므로 HsmManager 가 초기화되어 있어야 합니다. */ public byte[] encryptRsa(String keyAlias, byte[] plaintext) throws HsmException { return encryptRsa(getPublicKey(keyAlias), plaintext); } /** * RSA 암호화 - 전달받은 공개키로 JVM 소프트웨어 암호화. * HSM 없이도 호출 가능합니다. */ public byte[] encryptRsa(PublicKey publicKey, byte[] plaintext) throws HsmException { try { // Provider 미명시 → JVM 소프트웨어 Provider(SunRsaSign) 자동 선택 Cipher cipher = Cipher.getInstance(RSA_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, publicKey); return cipher.doFinal(plaintext); } catch (Exception e) { throw new HsmException("RSA 암호화 실패: " + e.getMessage(), e); } } /** * RSA 복호화 - 개인키 핸들을 HSM 에서 가져와 HSM 내부에서 복호화. * HsmManager 가 초기화되지 않은 경우 JVM 소프트웨어로 fallback 합니다. * * @param keyAlias HSM KeyStore 의 개인키 별칭 * @param pin HSM 슬롯 PIN (null 이면 HsmManager 초기화 시 사용한 PIN 재사용) * @param ciphertext 암호문 바이트 */ public byte[] decryptRsa(String keyAlias, char[] pin, byte[] ciphertext) throws HsmException { try { HsmManager hsmManager = HsmManager.getInstance(); if (hsmManager.isReady()) { // HSM 내부 복호화: pkcs11Provider 명시 필수 Provider pkcs11Provider = hsmManager.getPkcs11Provider(); java.security.Key key = hsmManager.getKeyStore().getKey(keyAlias, pin); if (!(key instanceof PrivateKey)) { throw new HsmException("개인키를 찾을 수 없습니다. alias=" + keyAlias); } Cipher cipher = Cipher.getInstance(RSA_ALGORITHM, pkcs11Provider); cipher.init(Cipher.DECRYPT_MODE, (PrivateKey) key); return cipher.doFinal(ciphertext); } else { throw new HsmException("RSA 복호화 불가: HsmManager 가 초기화되지 않았습니다. alias=" + keyAlias); } } catch (HsmException e) { throw e; } catch (Exception e) { throw new HsmException("RSA 복호화 실패: " + e.getMessage(), e); } } // ------------------------------------------------------------------------- // AES // ------------------------------------------------------------------------- /** * AES/CBC/PKCS5Padding 암호화. * extractable 키: provider=null 로 JVM 소프트웨어 암호화. * non-extractable 키: provider 에 pkcs11Provider 를 전달해야 합니다. * * @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키) * @param iv 초기화 벡터 (16바이트) * @param plaintext 평문 바이트 * @param provider null 이면 JVM 기본 Provider 사용 */ public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext, Provider provider) throws HsmException { try { Cipher cipher = (provider != null) ? Cipher.getInstance(AES_ALGORITHM, provider) : Cipher.getInstance(AES_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv)); return cipher.doFinal(plaintext); } catch (Exception e) { throw new HsmException("AES 암호화 실패: " + e.getMessage(), e); } } /** AES 암호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */ public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext) throws HsmException { return encryptAes(secretKey, iv, plaintext, null); } /** * AES/CBC/PKCS5Padding 복호화. * extractable 키: provider=null 로 JVM 소프트웨어 복호화. * non-extractable 키: provider 에 pkcs11Provider 를 전달해야 합니다. * * @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키) * @param iv 초기화 벡터 (16바이트) * @param ciphertext 암호문 바이트 * @param provider null 이면 JVM 기본 Provider 사용 */ public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext, Provider provider) throws HsmException { try { Cipher cipher = (provider != null) ? Cipher.getInstance(AES_ALGORITHM, provider) : Cipher.getInstance(AES_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv)); return cipher.doFinal(ciphertext); } catch (Exception e) { throw new HsmException("AES 복호화 실패: " + e.getMessage(), e); } } /** AES 복호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */ public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext) throws HsmException { return decryptAes(secretKey, iv, ciphertext, null); } }