From acdb58049c38e22188551215b9e3382d83554fe6 Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 28 Apr 2026 20:52:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20SafeNet=20HSM=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(JDK=208=20/=20SunPKCS11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HsmManager: SunPKCS11 Provider 초기화, Lifecycle 관리 - JDK 8 / JDK 9+ 분기를 리플렉션으로 처리하는 createProvider() 추가 - PropManager DB에서 PKCS11_CONFIG / PIN 읽어 초기화 - HsmCryptoService: RSA / AES 암복호화 서비스 - encryptRsa: 공개키 기반 JVM 소프트웨어 암호화 - decryptRsa: HSM 내부 복호화 (미초기화 시 HsmException 발생) - encryptAes / decryptAes: AES/CBC/PKCS5Padding - HsmException: HSM 전용 커스텀 예외 - pkcs11-dev.cfg: SoftHSM2용 (slotListIndex = 0) - pkcs11-prod.cfg: SafeNet ProtectServer 운영용 (slot = 0) - HsmSoftHsm2IntegrationTest: 저수준 PKCS11 직접 테스트 (8개) - HsmManagerServiceTest: HsmManager/HsmCryptoService 경유 테스트 (8개) Mockito + GenericApplicationContext로 DB 없이 실행 가능 Co-Authored-By: Claude Sonnet 4.6 --- .../eai/common/hsm/HsmCryptoService.java | 192 +++++++++++ .../eactive/eai/common/hsm/HsmException.java | 13 + .../eactive/eai/common/hsm/HsmManager.java | 175 ++++++++++ src/main/resources/config/hsm/pkcs11-dev.cfg | 6 + src/main/resources/config/hsm/pkcs11-prod.cfg | 8 + .../eai/common/hsm/HsmManagerServiceTest.java | 304 +++++++++++++++++ .../hsm/HsmSoftHsm2IntegrationTest.java | 310 ++++++++++++++++++ 7 files changed, 1008 insertions(+) create mode 100644 src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java create mode 100644 src/main/java/com/eactive/eai/common/hsm/HsmException.java create mode 100644 src/main/java/com/eactive/eai/common/hsm/HsmManager.java create mode 100644 src/main/resources/config/hsm/pkcs11-dev.cfg create mode 100644 src/main/resources/config/hsm/pkcs11-prod.cfg create mode 100644 src/test/java/com/eactive/eai/common/hsm/HsmManagerServiceTest.java create mode 100644 src/test/java/com/eactive/eai/common/hsm/HsmSoftHsm2IntegrationTest.java diff --git a/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java b/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java new file mode 100644 index 0000000..ff9b6d7 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java @@ -0,0 +1,192 @@ +package com.eactive.eai.common.hsm; + +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.PublicKey; +import java.security.cert.Certificate; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; + +import org.springframework.stereotype.Service; + +import com.eactive.eai.common.util.Logger; + +/** + * SafeNet ProtectServer HSM 암복호화 서비스 (JDK 8) + * + * [키 조회] + * getPublicKey - 공개키 반환 (외부 노출) + * getSecretKey - AES 대칭키 반환 (CKA_EXTRACTABLE=true 필요, 외부 노출) + * getPrivateKey - 내부 전용 (개인키는 HSM 외부로 반출하지 않는 것이 원칙) + * + * [암복호화] + * encryptRsa - 공개키로 JVM 소프트웨어 암호화 (HSM 불필요) + * decryptRsa - alias 기반, HSM 내부 복호화 (HsmManager 미초기화 시 JVM fallback) + * encryptAes - SecretKey 객체로 AES/CBC 암호화 + * decryptAes - SecretKey 객체로 AES/CBC 복호화 + */ +@Service +public class HsmCryptoService { + + 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"; + + // ------------------------------------------------------------------------- + // 키 조회 (외부 노출) + // ------------------------------------------------------------------------- + + /** + * HSM KeyStore 에서 공개키를 반환합니다. + * 공개키는 외부 노출에 제약이 없으며, 직접 암호화에 활용할 수 있습니다. + */ + public PublicKey getPublicKey(String keyAlias) throws HsmException { + checkReady(); + try { + Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias); + if (cert == null) { + throw new HsmException("인증서를 찾을 수 없습니다. alias=" + keyAlias); + } + return cert.getPublicKey(); + } catch (HsmException e) { + throw e; + } catch (Exception e) { + throw new HsmException("공개키 조회 실패: " + e.getMessage(), e); + } + } + + /** + * HSM KeyStore 에서 AES 대칭키를 반환합니다. + * HSM 키 생성 시 CKA_EXTRACTABLE=true (ctkmu -x 플래그) 로 생성된 키만 반환됩니다. + */ + public SecretKey getSecretKey(String keyAlias) throws HsmException { + checkReady(); + try { + KeyStore keyStore = HsmManager.getInstance().getKeyStore(); + java.security.Key key = keyStore.getKey(keyAlias, null); + if (!(key instanceof SecretKey)) { + throw new HsmException("AES 키를 찾을 수 없습니다 (CKA_EXTRACTABLE=true 확인 필요). alias=" + keyAlias); + } + return (SecretKey) key; + } catch (HsmException e) { + throw e; + } catch (Exception e) { + throw new HsmException("AES 키 조회 실패: " + e.getMessage(), e); + } + } + + // ------------------------------------------------------------------------- + // 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 암호화. + * HSM 없이도 호출 가능합니다 (getSecretKey 로 키를 미리 가져온 경우). + * + * @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키) + * @param iv 초기화 벡터 (16바이트) + * @param plaintext 평문 바이트 + */ + public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext) throws HsmException { + try { + Cipher cipher = 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/CBC/PKCS5Padding 복호화. + * HSM 없이도 호출 가능합니다 (getSecretKey 로 키를 미리 가져온 경우). + * + * @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키) + * @param iv 초기화 벡터 (16바이트) + * @param ciphertext 암호문 바이트 + */ + public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext) throws HsmException { + try { + Cipher cipher = 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); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private void checkReady() throws HsmException { + if (!HsmManager.getInstance().isReady()) { + throw new HsmException("HsmManager 가 초기화되지 않았습니다."); + } + } +} diff --git a/src/main/java/com/eactive/eai/common/hsm/HsmException.java b/src/main/java/com/eactive/eai/common/hsm/HsmException.java new file mode 100644 index 0000000..f6c2e74 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/hsm/HsmException.java @@ -0,0 +1,13 @@ +package com.eactive.eai.common.hsm; + +public class HsmException extends Exception { + private static final long serialVersionUID = 1L; + + public HsmException(String msg) { + super(msg); + } + + public HsmException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/src/main/java/com/eactive/eai/common/hsm/HsmManager.java b/src/main/java/com/eactive/eai/common/hsm/HsmManager.java new file mode 100644 index 0000000..374ef1a --- /dev/null +++ b/src/main/java/com/eactive/eai/common/hsm/HsmManager.java @@ -0,0 +1,175 @@ +package com.eactive.eai.common.hsm; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.security.KeyStore; +import java.security.Provider; +import java.security.Security; + +import org.springframework.stereotype.Component; + +import com.eactive.eai.common.exception.ExceptionUtil; +import com.eactive.eai.common.lifecycle.Lifecycle; +import com.eactive.eai.common.lifecycle.LifecycleException; +import com.eactive.eai.common.lifecycle.LifecycleListener; +import com.eactive.eai.common.lifecycle.LifecycleSupport; +import com.eactive.eai.common.property.PropManager; +import com.eactive.eai.common.util.ApplicationContextProvider; +import com.eactive.eai.common.util.Logger; + +/** + * SafeNet ProtectServer HSM 연동 관리자 (JDK 8 / SunPKCS11) + * + * PropManager 그룹 "HSM" 에서 읽는 키: + * PKCS11_CONFIG - pkcs11.cfg 파일 내용 (name/library/slot 설정을 DB에 직접 저장) + * PIN - HSM 슬롯 PIN + * + * PKCS11_CONFIG 값 예시 (개행은 \n 으로 입력): + * name = ProtectServer + * library = /opt/safenet/protecttoolkit5/ptk/lib/libcryptoki.so + * slot = 0 + */ +@Component +public class HsmManager implements Lifecycle { + + static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT); + + private static final String GROUP_NAME = "HSM"; + private static final String PROP_CONFIG = "PKCS11_CONFIG"; + private static final String PROP_PIN = "PIN"; + + private Provider pkcs11Provider; + private KeyStore keyStore; + private boolean started; + + private final LifecycleSupport lifecycle = new LifecycleSupport(this); + + private HsmManager() { + } + + public static HsmManager getInstance() { + return ApplicationContextProvider.getContext().getBean(HsmManager.class); + } + + @Override + public void start() throws LifecycleException { + if (started) { + throw new LifecycleException("RECEAIHSM001"); + } + lifecycle.fireLifecycleEvent(STARTING_EVENT, this); + + try { + init(); + } catch (Exception e) { + throw new LifecycleException(ExceptionUtil.getErrorCode(e, "RECEAIHSM002")); + } + + started = true; + lifecycle.fireLifecycleEvent(STARTED_EVENT, this); + } + + private void init() throws Exception { + String configContent = PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG); + String pin = PropManager.getInstance().getProperty(GROUP_NAME, PROP_PIN); + + if (configContent == null || configContent.trim().isEmpty()) { + logger.warn("HsmManager] PKCS11_CONFIG 가 설정되지 않았습니다. HSM 초기화를 건너뜁니다."); + return; + } + + pkcs11Provider = createProvider(configContent.trim()); + + // 이미 등록된 Provider 가 있으면 제거 후 재등록 + Provider existing = Security.getProvider(pkcs11Provider.getName()); + if (existing != null) { + Security.removeProvider(existing.getName()); + } + Security.addProvider(pkcs11Provider); + + keyStore = KeyStore.getInstance("PKCS11", pkcs11Provider); + char[] pinChars = (pin != null) ? pin.toCharArray() : null; + keyStore.load(null, pinChars); + + logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName()); + } + + /** + * JDK 버전에 따라 SunPKCS11 Provider 를 생성한다. + * + * JDK 8 : SunPKCS11(InputStream) 생성자를 리플렉션으로 호출 + * JDK 9+: Provider.configure(configFilePath) 를 리플렉션으로 호출 + * + * 두 경로 모두 리플렉션을 사용하므로 컴파일 타임에 JDK 버전 의존성이 없다. + */ + static Provider createProvider(String cfgContent) throws Exception { + String javaVersion = System.getProperty("java.version"); + if (javaVersion.startsWith("1.")) { + // JDK 8: new SunPKCS11(InputStream) + InputStream is = new ByteArrayInputStream(cfgContent.getBytes("UTF-8")); + return (Provider) Class.forName("sun.security.pkcs11.SunPKCS11") + .getConstructor(InputStream.class) + .newInstance(is); + } else { + // JDK 9+: Security.getProvider("SunPKCS11").configure(configFilePath) + // configure() 는 JDK 9 에서 추가된 메서드이므로 리플렉션으로 호출 + File tmp = File.createTempFile("pkcs11-hsm-", ".cfg"); + tmp.deleteOnExit(); + Files.write(tmp.toPath(), cfgContent.getBytes("UTF-8")); + Provider base = Security.getProvider("SunPKCS11"); + Method configure = Provider.class.getMethod("configure", String.class); + return (Provider) configure.invoke(base, tmp.getAbsolutePath()); + } + } + + @Override + public void stop() throws LifecycleException { + if (!started) { + throw new LifecycleException("RECEAIHSM003"); + } + lifecycle.fireLifecycleEvent(STOPING_EVENT, this); + + if (pkcs11Provider != null) { + Security.removeProvider(pkcs11Provider.getName()); + } + pkcs11Provider = null; + keyStore = null; + started = false; + + lifecycle.fireLifecycleEvent(STOPPED_EVENT, this); + } + + @Override + public void addLifecycleListener(LifecycleListener listener) { + lifecycle.addLifecycleListener(listener); + } + + @Override + public LifecycleListener[] findLifecycleListeners() { + return lifecycle.findLifecycleListeners(); + } + + @Override + public void removeLifecycleListener(LifecycleListener listener) { + lifecycle.removeLifecycleListener(listener); + } + + @Override + public boolean isStarted() { + return started; + } + + public Provider getPkcs11Provider() { + return pkcs11Provider; + } + + public KeyStore getKeyStore() { + return keyStore; + } + + public boolean isReady() { + return started && pkcs11Provider != null && keyStore != null; + } +} diff --git a/src/main/resources/config/hsm/pkcs11-dev.cfg b/src/main/resources/config/hsm/pkcs11-dev.cfg new file mode 100644 index 0000000..fe54a4c --- /dev/null +++ b/src/main/resources/config/hsm/pkcs11-dev.cfg @@ -0,0 +1,6 @@ +# SoftHSM2 for Windows - 개발 환경 +# https://github.com/disig/SoftHSM2-for-Windows/releases 에서 설치 +# 토큰 초기화: softhsm2-util --init-token --slot 0 --label "dev-token" --pin 1234 --so-pin 1234 +name = SoftHSM +library = C:/SoftHSM2/lib/softhsm2-x64.dll +slotListIndex = 0 diff --git a/src/main/resources/config/hsm/pkcs11-prod.cfg b/src/main/resources/config/hsm/pkcs11-prod.cfg new file mode 100644 index 0000000..6fbd6e8 --- /dev/null +++ b/src/main/resources/config/hsm/pkcs11-prod.cfg @@ -0,0 +1,8 @@ +# SafeNet ProtectServer Network HSM - 운영 환경 +# 환경변수 필요: +# ET_HSM_NETCLIENT_SERVERLIST= +# ET_HSM_NETCLIENT_SERVERPORT=9000 +# LD_LIBRARY_PATH=/opt/safenet/protecttoolkit5/ptk/lib +name = ProtectServer +library = /opt/safenet/protecttoolkit5/ptk/lib/libcryptoki.so +slot = 0 diff --git a/src/test/java/com/eactive/eai/common/hsm/HsmManagerServiceTest.java b/src/test/java/com/eactive/eai/common/hsm/HsmManagerServiceTest.java new file mode 100644 index 0000000..694c8e8 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/hsm/HsmManagerServiceTest.java @@ -0,0 +1,304 @@ +package com.eactive.eai.common.hsm; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.nio.file.Files; +import java.security.PublicKey; +import java.util.concurrent.TimeUnit; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.mockito.Mockito; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.context.support.GenericApplicationContext; + +import com.eactive.eai.common.property.PropManager; +import com.eactive.eai.common.util.ApplicationContextProvider; + +/** + * HsmManager / HsmCryptoService 통합 테스트 (JDK 8 / SunPKCS11 / SoftHSM2) + * + * 사전 조건: + * SoftHSM2 설치: https://github.com/disig/SoftHSM2-for-Windows/releases + * → C:\SoftHSM2 에 설치 (미설치 시 테스트 자동 건너뜀) + * + * PropManager 는 DB 없이 Mockito Mock 으로 대체하여 테스트합니다. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class HsmManagerServiceTest { + + private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll"; + private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe"; + private static final String TOKEN_LABEL = "eapim-test"; + private static final String PIN = "1234"; + private static final String RSA_ALIAS = "test-rsa-key"; + private static final String AES_ALIAS = "test-aes-key"; + + private static HsmManager hsmManager; + private static HsmCryptoService hsmCryptoService; + private static GenericApplicationContext springContext; + private static File tempCfgFile; + + // ------------------------------------------------------------------------- + // 설정 / 해제 + // ------------------------------------------------------------------------- + + @BeforeAll + static void setUp() throws Exception { + System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf"); + assumeTrue(new File(SOFTHSM2_DLL).exists(), + "SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀"); + + // 1. 토큰 초기화 (없으면 신규 생성, 있으면 기존 사용) + ensureTokenInitialized(); + + // 2. PKCS11 설정 문자열 구성 (slotListIndex = 0 → 첫 번째 초기화 토큰) + String cfgContent = "name = SoftHSM\n" + + "library = " + SOFTHSM2_DLL.replace("\\", "/") + "\n" + + "slotListIndex = 0\n"; + + // keytool 호출 시 파일 경로가 필요하므로 임시 파일에도 저장 + tempCfgFile = File.createTempFile("pkcs11-svc-test-", ".cfg"); + Files.write(tempCfgFile.toPath(), cfgContent.getBytes("UTF-8")); + + // 3. PropManager Mock - DB 없이 HSM 설정값 반환 + PropManager mockPropManager = Mockito.mock(PropManager.class); + Mockito.when(mockPropManager.getProperty("HSM", "PKCS11_CONFIG")).thenReturn(cfgContent); + Mockito.when(mockPropManager.getProperty("HSM", "PIN")).thenReturn(PIN); + + // 4. HsmManager 인스턴스 생성 (private 생성자 → 리플렉션) + Constructor ctor = HsmManager.class.getDeclaredConstructor(); + ctor.setAccessible(true); + hsmManager = ctor.newInstance(); + + // 5. Spring ApplicationContext 구성 + // ApplicationContextProvider.context 를 세팅하여 + // HsmManager.getInstance() / PropManager.getInstance() 가 동작하게 함 + springContext = new GenericApplicationContext(); + springContext.getBeanFactory().registerSingleton("propManager", mockPropManager); + springContext.getBeanFactory().registerSingleton("hsmManager", hsmManager); + springContext.registerBeanDefinition("applicationContextProvider", + BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class) + .getBeanDefinition()); + springContext.refresh(); + + // 6. HsmManager 시작 (SunPKCS11 Provider 초기화 + KeyStore 오픈) + hsmManager.start(); + assertTrue(hsmManager.isReady(), "HsmManager 초기화 실패"); + System.out.println("[HsmManager] 초기화 완료. provider=" + hsmManager.getPkcs11Provider().getName()); + + // 7. RSA 키쌍 생성 (없는 경우에만) + if (!hsmManager.getKeyStore().containsAlias(RSA_ALIAS)) { + generateRsaKeyPair(); + hsmManager.getKeyStore().load(null, PIN.toCharArray()); // keytool 결과 반영 + } + + // 8. AES 키 생성 (없는 경우에만) + if (!hsmManager.getKeyStore().containsAlias(AES_ALIAS)) { + generateAesKey(); + } + + // 9. HsmCryptoService 생성 (HsmManager.getInstance() 를 내부에서 사용) + hsmCryptoService = new HsmCryptoService(); + } + + @AfterAll + static void tearDown() throws Exception { + if (hsmManager != null && hsmManager.isStarted()) { + hsmManager.stop(); + } + if (springContext != null) { + springContext.close(); + } + if (tempCfgFile != null) { + tempCfgFile.delete(); + } + } + + // ------------------------------------------------------------------------- + // 테스트: HsmManager 상태 + // ------------------------------------------------------------------------- + + @Test + @Order(1) + void testHsmManagerReady() { + assertTrue(hsmManager.isReady()); + assertNotNull(hsmManager.getPkcs11Provider()); + assertNotNull(hsmManager.getKeyStore()); + System.out.println("[HsmManager] isReady=true / provider=" + + hsmManager.getPkcs11Provider().getName()); + } + + // ------------------------------------------------------------------------- + // 테스트: HsmCryptoService - 키 조회 + // ------------------------------------------------------------------------- + + @Test + @Order(2) + void testGetPublicKey() throws Exception { + PublicKey pk = hsmCryptoService.getPublicKey(RSA_ALIAS); + assertNotNull(pk); + assertEquals("RSA", pk.getAlgorithm()); + System.out.println("[HsmCryptoService] getPublicKey: algorithm=" + pk.getAlgorithm() + + " / format=" + pk.getFormat()); + } + + @Test + @Order(3) + void testGetSecretKey() throws Exception { + SecretKey sk = hsmCryptoService.getSecretKey(AES_ALIAS); + assertNotNull(sk); + assertEquals("AES", sk.getAlgorithm()); + System.out.println("[HsmCryptoService] getSecretKey: algorithm=" + sk.getAlgorithm()); + } + + // ------------------------------------------------------------------------- + // 테스트: HsmCryptoService - RSA 암복호화 + // ------------------------------------------------------------------------- + + @Test + @Order(4) + void testEncryptRsaByAlias() throws Exception { + byte[] plaintext = "RSA alias 암호화 테스트".getBytes("UTF-8"); + byte[] ciphertext = hsmCryptoService.encryptRsa(RSA_ALIAS, plaintext); + assertNotNull(ciphertext); + assertTrue(ciphertext.length > 0); + System.out.println("[HsmCryptoService] encryptRsa(alias): ciphertext.length=" + ciphertext.length); + } + + @Test + @Order(5) + void testEncryptRsaByPublicKey() throws Exception { + PublicKey pk = hsmCryptoService.getPublicKey(RSA_ALIAS); + byte[] plaintext = "RSA PublicKey 직접 암호화 테스트".getBytes("UTF-8"); + byte[] ciphertext = hsmCryptoService.encryptRsa(pk, plaintext); + assertNotNull(ciphertext); + assertTrue(ciphertext.length > 0); + System.out.println("[HsmCryptoService] encryptRsa(PublicKey): ciphertext.length=" + ciphertext.length); + } + + @Test + @Order(6) + void testRsaRoundTrip() throws Exception { + byte[] plaintext = "HSM RSA 암복호화 통합 테스트 - HsmCryptoService".getBytes("UTF-8"); + + // 암호화: 공개키 → JVM 소프트웨어 (encryptRsa 내부에서 Provider 미명시) + byte[] ciphertext = hsmCryptoService.encryptRsa(RSA_ALIAS, plaintext); + + // 복호화: 개인키 핸들 → HSM 내부 (decryptRsa 내부에서 pkcs11Provider 명시) + byte[] decrypted = hsmCryptoService.decryptRsa(RSA_ALIAS, PIN.toCharArray(), ciphertext); + + assertArrayEquals(plaintext, decrypted); + System.out.println("[HsmCryptoService] RSA 암복호화 성공: " + new String(decrypted, "UTF-8")); + } + + // ------------------------------------------------------------------------- + // 테스트: HsmCryptoService - AES 암복호화 + // ------------------------------------------------------------------------- + + @Test + @Order(7) + void testAesRoundTrip() throws Exception { + byte[] plaintext = "HSM AES 암복호화 통합 테스트 - HsmCryptoService".getBytes("UTF-8"); + byte[] iv = "1234567890123456".getBytes("UTF-8"); // 16바이트 + + SecretKey sk = hsmCryptoService.getSecretKey(AES_ALIAS); + + byte[] ciphertext = hsmCryptoService.encryptAes(sk, iv, plaintext); + byte[] decrypted = hsmCryptoService.decryptAes(sk, iv, ciphertext); + + assertArrayEquals(plaintext, decrypted); + System.out.println("[HsmCryptoService] AES 암복호화 성공: " + new String(decrypted, "UTF-8")); + } + + // ------------------------------------------------------------------------- + // 테스트: HsmManager Lifecycle - stop / restart + // ------------------------------------------------------------------------- + + @Test + @Order(8) + void testHsmManagerStopAndRestart() throws Exception { + // stop + hsmManager.stop(); + assertFalse(hsmManager.isStarted()); + assertFalse(hsmManager.isReady()); + System.out.println("[HsmManager] stop 완료"); + + // restart - PropManager Mock 에서 재설정 읽어 재초기화 + hsmManager.start(); + assertTrue(hsmManager.isStarted()); + assertTrue(hsmManager.isReady()); + System.out.println("[HsmManager] restart 완료. provider=" + + hsmManager.getPkcs11Provider().getName()); + } + + // ------------------------------------------------------------------------- + // 헬퍼 + // ------------------------------------------------------------------------- + + private static void ensureTokenInitialized() throws Exception { + ProcessBuilder pb = new ProcessBuilder( + SOFTHSM2_UTIL, "--init-token", "--free", + "--label", TOKEN_LABEL, + "--pin", PIN, + "--so-pin", PIN); + pb.redirectErrorStream(true); + Process p = pb.start(); + String out = readOutput(p); + p.waitFor(10, TimeUnit.SECONDS); + System.out.println("[SoftHSM2] init-token: " + out.trim()); + } + + private static void generateRsaKeyPair() throws Exception { + ProcessBuilder pb = new ProcessBuilder( + "keytool", "-genkeypair", + "-alias", RSA_ALIAS, + "-keyalg", "RSA", + "-keysize", "2048", + "-storetype", "PKCS11", + "-providerclass", "sun.security.pkcs11.SunPKCS11", + "-providerarg", tempCfgFile.getAbsolutePath(), + "-keystore", "NONE", + "-storepass", PIN, + "-dname", "CN=HSM Test, O=eActive, C=KR", + "-noprompt"); + pb.redirectErrorStream(true); + Process p = pb.start(); + String out = readOutput(p); + if (!p.waitFor(30, TimeUnit.SECONDS)) { + p.destroy(); + throw new RuntimeException("keytool -genkeypair timeout"); + } + System.out.println("[keytool] genkeypair exit=" + p.exitValue() + ": " + out.trim()); + } + + private static void generateAesKey() throws Exception { + KeyGenerator kg = KeyGenerator.getInstance("AES", hsmManager.getPkcs11Provider()); + kg.init(256); + SecretKey sk = kg.generateKey(); + hsmManager.getKeyStore().setKeyEntry(AES_ALIAS, sk, null, null); + System.out.println("[HsmManager] AES-256 키 생성 완료. alias=" + AES_ALIAS); + } + + private static String readOutput(Process p) throws Exception { + InputStream is = p.getInputStream(); + byte[] buf = new byte[4096]; + StringBuilder sb = new StringBuilder(); + int len; + while ((len = is.read(buf)) != -1) { + sb.append(new String(buf, 0, len, "UTF-8")); + } + return sb.toString(); + } +} diff --git a/src/test/java/com/eactive/eai/common/hsm/HsmSoftHsm2IntegrationTest.java b/src/test/java/com/eactive/eai/common/hsm/HsmSoftHsm2IntegrationTest.java new file mode 100644 index 0000000..ce8ef3a --- /dev/null +++ b/src/test/java/com/eactive/eai/common/hsm/HsmSoftHsm2IntegrationTest.java @@ -0,0 +1,310 @@ +package com.eactive.eai.common.hsm; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.security.Key; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.PublicKey; +import java.security.Security; +import java.security.cert.Certificate; +import java.util.concurrent.TimeUnit; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +/** + * SoftHSM2 연동 통합 테스트 (JDK 8 / SunPKCS11) + * + * 사전 조건: + * 1. SoftHSM2 설치: https://github.com/disig/SoftHSM2-for-Windows/releases + * → SoftHSM2-*-x64.msi 다운로드 후 C:\SoftHSM2 에 설치 + * 2. 토큰 초기화 (최초 1회): + * "C:\SoftHSM2\bin\softhsm2-util.exe" --init-token --free --label "eapim-test" --pin 1234 --so-pin 1234 + * → 이미 초기화된 경우 @BeforeAll 에서 자동 처리 + * + * SoftHSM2 미설치 시 모든 테스트는 자동으로 건너뜀. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class HsmSoftHsm2IntegrationTest { + + private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll"; + private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe"; + private static final String TOKEN_LABEL = "eapim-test"; + private static final String PIN = "1234"; + private static final String RSA_ALIAS = "test-rsa-key"; + private static final String AES_ALIAS = "test-aes-key"; + + private static Provider provider; + private static KeyStore keyStore; + private static File tempCfgFile; + + // ------------------------------------------------------------------------- + // 설정 / 해제 + // ------------------------------------------------------------------------- + + @BeforeAll + static void setUpHsm() throws Exception { + System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf"); + assumeTrue(new File(SOFTHSM2_DLL).exists(), + "SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀"); + + // 1. 토큰 초기화 (이미 존재하면 출력만 남기고 계속 진행) + initToken(); + + // 2. pkcs11 설정 내용 (slotListIndex = 0 → 첫 번째 초기화된 토큰 사용) + String cfgContent = "name = SoftHSM\n" + + "library = " + SOFTHSM2_DLL.replace("\\", "/") + "\n" + + "slotListIndex = 0\n"; + + // keytool 은 파일 경로를 요구하므로 임시 파일에도 저장 + tempCfgFile = File.createTempFile("pkcs11-test-", ".cfg"); + Files.write(tempCfgFile.toPath(), cfgContent.getBytes("UTF-8")); + + // 3. SunPKCS11 Provider 초기화 (JDK 8 / JDK 9+ 공용) + provider = HsmManager.createProvider(cfgContent); + + Provider existing = Security.getProvider(provider.getName()); + if (existing != null) { + Security.removeProvider(existing.getName()); + } + Security.addProvider(provider); + + // 4. PKCS11 KeyStore 오픈 + keyStore = KeyStore.getInstance("PKCS11", provider); + keyStore.load(null, PIN.toCharArray()); + + // 5. RSA 키쌍 생성 (keytool 경유 → 자체서명 인증서 포함) + if (!keyStore.containsAlias(RSA_ALIAS)) { + generateRsaKeyPair(); + keyStore.load(null, PIN.toCharArray()); // keytool 결과 반영 + } + + // 6. AES 키 생성 (KeyGenerator 경유) + if (!keyStore.containsAlias(AES_ALIAS)) { + generateAesKey(); + } + } + + @AfterAll + static void tearDown() { + if (provider != null) { + Security.removeProvider(provider.getName()); + } + if (tempCfgFile != null) { + tempCfgFile.delete(); + } + } + + // ------------------------------------------------------------------------- + // 테스트: Provider / KeyStore + // ------------------------------------------------------------------------- + + @Test + @Order(1) + void testProviderInitialized() { + assertNotNull(provider); + assertTrue(provider.getName().startsWith("SunPKCS11"), + "예상 Provider 이름: SunPKCS11-*, 실제: " + provider.getName()); + System.out.println("[HSM] Provider: " + provider.getName() + + " / version: " + provider.getVersion()); + } + + @Test + @Order(2) + void testKeyStoreContainsRsaAlias() throws Exception { + assertTrue(keyStore.containsAlias(RSA_ALIAS), + "RSA 키 없음. alias=" + RSA_ALIAS); + } + + @Test + @Order(3) + void testKeyStoreContainsAesAlias() throws Exception { + assertTrue(keyStore.containsAlias(AES_ALIAS), + "AES 키 없음. alias=" + AES_ALIAS); + } + + // ------------------------------------------------------------------------- + // 테스트: 키 조회 (HsmCryptoService.getPublicKey / getSecretKey 해당) + // ------------------------------------------------------------------------- + + @Test + @Order(4) + void testGetPublicKey() throws Exception { + Certificate cert = keyStore.getCertificate(RSA_ALIAS); + assertNotNull(cert, "인증서 없음. alias=" + RSA_ALIAS); + + PublicKey pk = cert.getPublicKey(); + assertNotNull(pk); + assertEquals("RSA", pk.getAlgorithm()); + System.out.println("[HSM] PublicKey algorithm=" + pk.getAlgorithm() + + " / format=" + pk.getFormat()); + } + + @Test + @Order(5) + void testGetSecretKey() throws Exception { + Key key = keyStore.getKey(AES_ALIAS, null); + assertNotNull(key, "AES 키 없음. alias=" + AES_ALIAS); + assertInstanceOf(SecretKey.class, key); + assertEquals("AES", key.getAlgorithm()); + System.out.println("[HSM] SecretKey algorithm=" + key.getAlgorithm()); + } + + // ------------------------------------------------------------------------- + // 테스트: RSA 암복호화 (HsmCryptoService.encryptRsa / decryptRsa 해당) + // ------------------------------------------------------------------------- + + @Test + @Order(6) + void testRsaEncrypt() throws Exception { + PublicKey publicKey = keyStore.getCertificate(RSA_ALIAS).getPublicKey(); + + // Provider 미명시 → JVM SunRsaSign 으로 암호화 (HsmCryptoService.encryptRsa 동작) + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.ENCRYPT_MODE, publicKey); + byte[] ciphertext = cipher.doFinal("테스트 평문".getBytes("UTF-8")); + + assertNotNull(ciphertext); + assertTrue(ciphertext.length > 0); + System.out.println("[HSM] RSA 암호화 성공. ciphertext.length=" + ciphertext.length + + " / provider=" + cipher.getProvider().getName()); + } + + @Test + @Order(7) + void testRsaEncryptDecryptRoundTrip() throws Exception { + byte[] plaintext = "HSM RSA 암복호화 테스트 - eapim-online".getBytes("UTF-8"); + + // 암호화: 공개키 → JVM 소프트웨어 (Provider 미명시) + PublicKey publicKey = keyStore.getCertificate(RSA_ALIAS).getPublicKey(); + Cipher encCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + encCipher.init(Cipher.ENCRYPT_MODE, publicKey); + byte[] ciphertext = encCipher.doFinal(plaintext); + + // 복호화: 개인키 핸들 → HSM 내부 수행 (pkcs11Provider 명시) + PrivateKey privateKey = (PrivateKey) keyStore.getKey(RSA_ALIAS, PIN.toCharArray()); + Cipher decCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding", provider); + decCipher.init(Cipher.DECRYPT_MODE, privateKey); + byte[] decrypted = decCipher.doFinal(ciphertext); + + assertArrayEquals(plaintext, decrypted); + System.out.println("[HSM] RSA 암복호화 성공." + + " enc.provider=" + encCipher.getProvider().getName() + + " / dec.provider=" + decCipher.getProvider().getName() + + " / plaintext=" + new String(decrypted, "UTF-8")); + } + + // ------------------------------------------------------------------------- + // 테스트: AES 암복호화 (HsmCryptoService.encryptAes / decryptAes 해당) + // ------------------------------------------------------------------------- + + @Test + @Order(8) + void testAesEncryptDecryptRoundTrip() throws Exception { + byte[] plaintext = "HSM AES 암복호화 테스트 - eapim-online".getBytes("UTF-8"); + byte[] iv = "1234567890123456".getBytes("UTF-8"); // 16바이트 IV + + SecretKey secretKey = (SecretKey) keyStore.getKey(AES_ALIAS, null); + + // 암호화 + Cipher encCipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + encCipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv)); + byte[] ciphertext = encCipher.doFinal(plaintext); + + // 복호화 + Cipher decCipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + decCipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv)); + byte[] decrypted = decCipher.doFinal(ciphertext); + + assertArrayEquals(plaintext, decrypted); + System.out.println("[HSM] AES 암복호화 성공. plaintext=" + new String(decrypted, "UTF-8")); + } + + // ------------------------------------------------------------------------- + // 헬퍼 + // ------------------------------------------------------------------------- + + private static void initToken() throws Exception { + ProcessBuilder pb = new ProcessBuilder( + SOFTHSM2_UTIL, "--init-token", "--free", + "--label", TOKEN_LABEL, + "--pin", PIN, + "--so-pin", PIN); + pb.redirectErrorStream(true); + Process p = pb.start(); + String output = readOutput(p); + p.waitFor(10, TimeUnit.SECONDS); + // 이미 초기화된 슬롯이 없으면 실패할 수 있으나 무시 (기존 토큰 재사용) + System.out.println("[SoftHSM2] init-token: " + output.trim()); + } + + /** + * keytool 로 RSA 키쌍 + 자체서명 인증서를 PKCS11 토큰에 생성. + * 인증서가 있어야 PKCS11 KeyStore 에서 alias 로 조회 가능. + */ + private static void generateRsaKeyPair() throws Exception { + ProcessBuilder pb = new ProcessBuilder( + "keytool", "-genkeypair", + "-alias", RSA_ALIAS, + "-keyalg", "RSA", + "-keysize", "2048", + "-storetype", "PKCS11", + "-providerclass", "sun.security.pkcs11.SunPKCS11", + "-providerarg", tempCfgFile.getAbsolutePath(), + "-keystore", "NONE", + "-storepass", PIN, + "-dname", "CN=HSM Test, O=eActive, C=KR", + "-noprompt"); + pb.redirectErrorStream(true); + Process p = pb.start(); + String output = readOutput(p); + boolean finished = p.waitFor(30, TimeUnit.SECONDS); + if (!finished) { + p.destroy(); + throw new RuntimeException("keytool -genkeypair timeout"); + } + System.out.println("[keytool] genkeypair exit=" + p.exitValue() + ": " + output.trim()); + } + + /** + * KeyGenerator 로 AES-256 키를 HSM 토큰에 직접 생성. + * SoftHSM2 의 AES 키는 기본적으로 extractable(추출 가능). + */ + private static void generateAesKey() throws Exception { + KeyGenerator kg = KeyGenerator.getInstance("AES", provider); + kg.init(256); + SecretKey sk = kg.generateKey(); + keyStore.setKeyEntry(AES_ALIAS, sk, null, null); + System.out.println("[HSM] AES-256 키 생성 완료. alias=" + AES_ALIAS); + } + + private static String readOutput(Process p) throws Exception { + InputStream is = p.getInputStream(); + byte[] buf = new byte[4096]; + StringBuilder sb = new StringBuilder(); + int len; + while ((len = is.read(buf)) != -1) { + sb.append(new String(buf, 0, len, "UTF-8")); + } + return sb.toString(); + } +} From a1a5a34874b2d1579f82981eef90a21f1e6ace28 Mon Sep 17 00:00:00 2001 From: curry772 Date: Wed, 29 Apr 2026 12:58:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20HSM=20=ED=82=A4=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=8F=20PropManager=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94,=20DB=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HsmCryptoService: getPublicKey/getSecretKey ConcurrentHashMap 캐시 추가 (HSM 통신 최소화) - HsmCryptoService: encryptAes/decryptAes Provider 파라미터 오버로드 추가 (non-extractable 키 지원) - HsmCryptoService: PropertyChangeListener 구현, HSM.CACHE_RELOAD_YN=Y 시 clearKeyCache() 자동 호출 - HsmCryptoService: @PostConstruct NPE 수정 - PropManager.getInstance() → @Autowired 직접 주입 - HsmManager: DB PKCS11_CONFIG 리터럴 \n → 실제 줄바꿈 치환 (파싱 오류 수정) - HsmManager: 기동 시 HSM 키 목록 로그 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../eai/common/hsm/HsmCryptoService.java | 106 +++++++++++++++--- .../eactive/eai/common/hsm/HsmManager.java | 10 +- 2 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java b/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java index ff9b6d7..3c1b9a1 100644 --- a/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java +++ b/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java @@ -1,57 +1,99 @@ 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 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 - 공개키 반환 (외부 노출) - * getSecretKey - AES 대칭키 반환 (CKA_EXTRACTABLE=true 필요, 외부 노출) - * getPrivateKey - 내부 전용 (개인키는 HSM 외부로 반출하지 않는 것이 원칙) + * getPublicKey - 공개키 반환 (외부 노출). 최초 1회만 HSM 통신, 이후 캐시 반환. + * getSecretKey - AES 대칭키 반환 (CKA_EXTRACTABLE=true 필요). 최초 1회만 HSM 통신, 이후 캐시 반환. * * [암복호화] * encryptRsa - 공개키로 JVM 소프트웨어 암호화 (HSM 불필요) - * decryptRsa - alias 기반, HSM 내부 복호화 (HsmManager 미초기화 시 JVM fallback) + * decryptRsa - alias 기반, HSM 내부 복호화 (매 호출마다 HSM 통신 발생 - 불가피) * encryptAes - SecretKey 객체로 AES/CBC 암호화 * decryptAes - SecretKey 객체로 AES/CBC 복호화 */ @Service -public class HsmCryptoService { +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"; + + // 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(); + } + } + // ------------------------------------------------------------------------- // 키 조회 (외부 노출) // ------------------------------------------------------------------------- /** - * HSM KeyStore 에서 공개키를 반환합니다. - * 공개키는 외부 노출에 제약이 없으며, 직접 암호화에 활용할 수 있습니다. + * HSM KeyStore 에서 공개키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다. */ public PublicKey getPublicKey(String keyAlias) throws HsmException { checkReady(); + PublicKey cached = publicKeyCache.get(keyAlias); + if (cached != null) { + return cached; + } try { Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias); if (cert == null) { throw new HsmException("인증서를 찾을 수 없습니다. alias=" + keyAlias); } - return cert.getPublicKey(); + PublicKey key = cert.getPublicKey(); + publicKeyCache.put(keyAlias, key); + logger.warn("HsmCryptoService] 공개키 캐시 등록: alias=" + keyAlias); + return key; } catch (HsmException e) { throw e; } catch (Exception e) { @@ -60,18 +102,25 @@ public class HsmCryptoService { } /** - * HSM KeyStore 에서 AES 대칭키를 반환합니다. + * HSM KeyStore 에서 AES 대칭키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다. * HSM 키 생성 시 CKA_EXTRACTABLE=true (ctkmu -x 플래그) 로 생성된 키만 반환됩니다. */ public SecretKey getSecretKey(String keyAlias) throws HsmException { checkReady(); + SecretKey cached = secretKeyCache.get(keyAlias); + if (cached != null) { + return cached; + } try { KeyStore keyStore = HsmManager.getInstance().getKeyStore(); java.security.Key key = keyStore.getKey(keyAlias, null); if (!(key instanceof SecretKey)) { throw new HsmException("AES 키를 찾을 수 없습니다 (CKA_EXTRACTABLE=true 확인 필요). alias=" + keyAlias); } - return (SecretKey) key; + SecretKey secretKey = (SecretKey) key; + secretKeyCache.put(keyAlias, secretKey); + logger.warn("HsmCryptoService] 대칭키 캐시 등록: alias=" + keyAlias); + return secretKey; } catch (HsmException e) { throw e; } catch (Exception e) { @@ -79,6 +128,13 @@ public class HsmCryptoService { } } + /** 캐시를 비웁니다. HSM 키 교체 후 재로드가 필요할 때 호출합니다. */ + public void clearKeyCache() { + secretKeyCache.clear(); + publicKeyCache.clear(); + logger.warn("HsmCryptoService] 키 캐시 초기화 완료"); + } + // ------------------------------------------------------------------------- // RSA // ------------------------------------------------------------------------- @@ -146,15 +202,19 @@ public class HsmCryptoService { /** * AES/CBC/PKCS5Padding 암호화. - * HSM 없이도 호출 가능합니다 (getSecretKey 로 키를 미리 가져온 경우). + * 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) throws HsmException { + public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext, Provider provider) throws HsmException { try { - Cipher cipher = Cipher.getInstance(AES_ALGORITHM); + 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) { @@ -162,17 +222,26 @@ public class HsmCryptoService { } } + /** AES 암호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */ + public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext) throws HsmException { + return encryptAes(secretKey, iv, plaintext, null); + } + /** * AES/CBC/PKCS5Padding 복호화. - * HSM 없이도 호출 가능합니다 (getSecretKey 로 키를 미리 가져온 경우). + * 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) throws HsmException { + public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext, Provider provider) throws HsmException { try { - Cipher cipher = Cipher.getInstance(AES_ALGORITHM); + 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) { @@ -180,6 +249,11 @@ public class HsmCryptoService { } } + /** AES 복호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */ + public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext) throws HsmException { + return decryptAes(secretKey, iv, ciphertext, null); + } + // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- diff --git a/src/main/java/com/eactive/eai/common/hsm/HsmManager.java b/src/main/java/com/eactive/eai/common/hsm/HsmManager.java index 374ef1a..ecad065 100644 --- a/src/main/java/com/eactive/eai/common/hsm/HsmManager.java +++ b/src/main/java/com/eactive/eai/common/hsm/HsmManager.java @@ -80,7 +80,7 @@ public class HsmManager implements Lifecycle { return; } - pkcs11Provider = createProvider(configContent.trim()); + pkcs11Provider = createProvider(configContent.trim().replace("\\n", "\n")); // 이미 등록된 Provider 가 있으면 제거 후 재등록 Provider existing = Security.getProvider(pkcs11Provider.getName()); @@ -94,6 +94,14 @@ public class HsmManager implements Lifecycle { keyStore.load(null, pinChars); logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName()); + + java.util.Enumeration aliases = keyStore.aliases(); + StringBuilder aliasList = new StringBuilder(); + while (aliases.hasMoreElements()) { + if (aliasList.length() > 0) aliasList.append(", "); + aliasList.append(aliases.nextElement()); + } + logger.warn("HsmManager] HSM 키 목록: [" + aliasList + "]"); } /**