feat: SafeNet HSM 연동 구현 (JDK 8 / SunPKCS11)

- 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 <noreply@anthropic.com>
This commit is contained in:
curry772
2026-04-28 20:52:14 +09:00
parent 81f7898eb1
commit acdb58049c
7 changed files with 1008 additions and 0 deletions
@@ -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 가 초기화되지 않았습니다.");
}
}
}
@@ -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);
}
}
@@ -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;
}
}