HSM 장애 대응 기능 개발

This commit is contained in:
curry772
2026-07-06 09:22:38 +09:00
parent 90633b3d26
commit af6b84f57d
2 changed files with 203 additions and 76 deletions
@@ -13,6 +13,7 @@ import javax.annotation.PostConstruct;
import javax.crypto.Cipher; import javax.crypto.Cipher;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -44,7 +45,8 @@ public class HsmCryptoService implements PropertyChangeListener {
private static final String PROP_GROUP = "HSM"; private static final String PROP_GROUP = "HSM";
private static final String PROP_CACHE_RELOAD_YN = "CACHE_RELOAD_YN"; private static final String PROP_CACHE_RELOAD_YN = "CACHE_RELOAD_YN";
private static final long CACHE_TTL_MS = 10 * 60 * 1000; // 10분, 필요시 PropManager로 외부화 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<T> { private static class CachedKey<T> {
final T key; final T key;
@@ -86,6 +88,9 @@ public class HsmCryptoService implements PropertyChangeListener {
if ("Y".equalsIgnoreCase(reloadYn)) { if ("Y".equalsIgnoreCase(reloadYn)) {
clearKeyCache(); clearKeyCache();
} }
String propCacheTtlSec = propManager.getProperty(PROP_GROUP, PROP_CACHE_TTL_SEC, "600");
CACHE_TTL_MS = (Integer.parseInt(propCacheTtlSec.trim())) * 1000; // 10분, 필요시 PropManager로 외부화
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -94,13 +99,17 @@ public class HsmCryptoService implements PropertyChangeListener {
/** /**
* HSM KeyStore 에서 공개키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다. * HSM KeyStore 에서 공개키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다.
* HSM 장애 시 만료된 캐시가 있으면 그것을 반환하여 서비스 연속성을 유지합니다.
*/ */
public PublicKey getPublicKey(String keyAlias) throws HsmException { public PublicKey getPublicKey(String keyAlias) throws HsmException {
checkReady(); // 1. 유효한 캐시 즉시 반환 (HSM 상태 무관)
CachedKey<PublicKey> cached = publicKeyCache.get(keyAlias); CachedKey<PublicKey> cached = publicKeyCache.get(keyAlias);
if (cached != null && !cached.isExpired()) { if (cached != null && !cached.isExpired()) {
return cached.key; return cached.key;
} }
// 2. 캐시 미스 또는 만료 → HSM 갱신 시도
if (HsmManager.getInstance().isReady()) {
try { try {
Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias); Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias);
if (cert == null) { if (cert == null) {
@@ -113,27 +122,56 @@ public class HsmCryptoService implements PropertyChangeListener {
} catch (HsmException e) { } catch (HsmException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
throw new HsmException("공개키 조회 실패: " + e.getMessage(), 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 KeyStore 에서 AES 대칭키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다.
* HSM 키 생성 시 CKA_EXTRACTABLE=true (ctkmu -x 플래그) 로 생성된 키만 반환됩니다. * 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 { public SecretKey getSecretKey(String keyAlias) throws HsmException {
checkReady(); // 1. 유효한 캐시 즉시 반환 (HSM 상태 무관)
CachedKey<SecretKey> cached = secretKeyCache.get(keyAlias); CachedKey<SecretKey> cached = secretKeyCache.get(keyAlias);
if (cached != null && !cached.isExpired()) { if (cached != null && !cached.isExpired()) {
return cached.key; return cached.key;
} }
// 2. 캐시 미스 또는 만료 → HSM 갱신 시도
if (HsmManager.getInstance().isReady()) {
try { try {
KeyStore keyStore = HsmManager.getInstance().getKeyStore(); KeyStore keyStore = HsmManager.getInstance().getKeyStore();
java.security.Key key = keyStore.getKey(keyAlias, null); java.security.Key key = keyStore.getKey(keyAlias, null);
if (key == null) { if (key == null) {
throw new HsmException("인증서를 찾을 수 없습니다. alias=" + keyAlias); throw new HsmException("를 찾을 수 없습니다. alias=" + keyAlias);
} }
SecretKey secretKey = (SecretKey) key; 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)); secretKeyCache.put(keyAlias, new CachedKey<>(secretKey));
logger.warn("HsmCryptoService] 대칭키 캐시 등록(갱신): alias=" + keyAlias); logger.warn("HsmCryptoService] 대칭키 캐시 등록(갱신): alias=" + keyAlias);
return secretKey; return secretKey;
@@ -141,10 +179,19 @@ public class HsmCryptoService implements PropertyChangeListener {
} catch (HsmException e) { } catch (HsmException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
throw new HsmException("AES 키 조회 실패: " + e.getMessage(), 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 키 교체 후 재로드가 필요할 때 호출합니다. */ /** 캐시를 비웁니다. HSM 키 교체 후 재로드가 필요할 때 호출합니다. */
public void clearKeyCache() { public void clearKeyCache() {
secretKeyCache.clear(); secretKeyCache.clear();
@@ -271,13 +318,4 @@ public class HsmCryptoService implements PropertyChangeListener {
return decryptAes(secretKey, iv, ciphertext, null); return decryptAes(secretKey, iv, ciphertext, null);
} }
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private void checkReady() throws HsmException {
if (!HsmManager.getInstance().isReady()) {
throw new HsmException("HsmManager 가 초기화되지 않았습니다.");
}
}
} }
@@ -5,10 +5,13 @@ import java.io.File;
import java.io.InputStream; import java.io.InputStream;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.file.Files; import java.nio.file.Files;
import java.security.AuthProvider;
import java.security.KeyStore; import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.Provider; import java.security.Provider;
import java.security.Security; import java.security.Security;
import java.util.Base64; import java.security.UnrecoverableKeyException;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
@@ -43,6 +46,18 @@ import com.eactive.eai.common.util.Logger;
* name = SoftHSM * name = SoftHSM
* library = C:/SoftHSM2/lib/softhsm2-x64.dll * library = C:/SoftHSM2/lib/softhsm2-x64.dll
* slotListIndex = 0 * slotListIndex = 0
*
* ---------------------------------------------------------------------
* [세션 누적 방지 / 회로차단 로직 추가]
* 기존 구현은 재로드마다 KeyStore.getInstance(...).load(null, pin) 을 새로
* 호출하여 PKCS11 세션(C_OpenSession)이 계속 누적되고, 결국 HSM 파티션의
* 최대 세션 수를 초과하면서 reload 가 영구적으로 실패하는 문제가 있었다.
* 이를 방지하기 위해:
* 1) 정상 상황에서는 "기존 keyStore 인스턴스"에 다시 load() 하여 세션 재사용
* 2) 실제 키 조회(probe)로 세션이 살아있는지 검증
* 3) 연속 실패가 임계치를 넘으면 Provider 자체를 logout 후 완전히 재생성
* 4) 재생성마저 실패하면 마지막으로 성공한 keyStore 를 유지 (서비스 연속성 우선)
* ---------------------------------------------------------------------
*/ */
@Component @Component
public class HsmManager implements Lifecycle { public class HsmManager implements Lifecycle {
@@ -121,6 +136,10 @@ public class HsmManager implements Lifecycle {
logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName()); logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName());
logKeyStore(this.keyStore);
}
private void logKeyStore(KeyStore keyStore) throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
java.util.Enumeration<String> aliases = keyStore.aliases(); java.util.Enumeration<String> aliases = keyStore.aliases();
StringBuilder aliasList = new StringBuilder(); StringBuilder aliasList = new StringBuilder();
while (aliases.hasMoreElements()) { while (aliases.hasMoreElements()) {
@@ -132,11 +151,8 @@ public class HsmManager implements Lifecycle {
java.security.Key key = keyStore.getKey(alias, null); java.security.Key key = keyStore.getKey(alias, null);
if (key instanceof SecretKey) { if (key instanceof SecretKey) {
SecretKey secretKey = (SecretKey) key; SecretKey secretKey = (SecretKey) key;
if(secretKey.getEncoded() != null) { if (secretKey.getEncoded() == null) {
String encBase64 = Base64.getEncoder().encodeToString(secretKey.getEncoded()); logger.warn("HsmManager] HSM - secretKey null {} : [{}]", alias, secretKey);
logger.debug("HsmManager] HSM - {} : [{}]", alias, encBase64);
} else {
logger.debug("HsmManager] HSM - secretKey null {} : [{}]", alias, secretKey);
} }
} }
} }
@@ -144,7 +160,7 @@ public class HsmManager implements Lifecycle {
} }
/** /**
* 별도 스레드에서 주기적으로 KeyStore.load() 를 다시 호출하여 * 별도 스레드에서 주기적으로 KeyStore 를 다시 로드하여
* HSM 에 새로 생성/추가된 키를 인식하도록 한다. * HSM 에 새로 생성/추가된 키를 인식하도록 한다.
*/ */
private void startReloadScheduler() { private void startReloadScheduler() {
@@ -172,35 +188,85 @@ public class HsmManager implements Lifecycle {
try { try {
reloadKeyStoreIfNeeded(); reloadKeyStoreIfNeeded();
} catch (Throwable t) { } catch (Throwable t) {
logger.warn("HsmManager] KeyStore 주기적 재로드 실패: " + t.getMessage()); logger.warn("HsmManager] KeyStore 주기적 재로드 실패: " + t.getMessage(), t);
} }
} }
/** /**
* KeyStore 를 다시 로드한다. 외부(getSecretKey 등)에서 키 미스 발생 시 * KeyStore 를 다시 로드한다. 외부(getSecretKey 등)에서 키 미스 발생 시
* 즉시 재시도용으로 직접 호출할 수도 있다. * 즉시 재시도용으로 직접 호출할 수도 있다.
*
* 세션 누적 방지를 위해 새 KeyStore 인스턴스를 만들지 않고,
* 기존 keyStore 객체에 다시 load() 하여 기존 PKCS11 세션을 재사용한다.
* 연속 실패가 임계치를 넘으면 Provider 자체를 재생성한다.
*/ */
public synchronized void reloadKeyStoreIfNeeded() throws Exception { public synchronized void reloadKeyStoreIfNeeded() throws Exception {
if (pkcs11Provider == null) { if (pkcs11Provider == null) {
return; // HSM 비활성화 상태 return; // HSM 비활성화 상태
} }
try {
if (keyStore != null) {
keyStore.load(null, pin);
logger.warn("HsmManager] KeyStore 재로드 완료 (기존 세션 재사용).");
} else {
KeyStore ks = KeyStore.getInstance("PKCS11", pkcs11Provider); KeyStore ks = KeyStore.getInstance("PKCS11", pkcs11Provider);
ks.load(null, pin); ks.load(null, pin);
this.keyStore = ks; // volatile 필드 교체 - 다른 스레드에서 즉시 가시성 확보 this.keyStore = ks;
logger.warn("HsmManager] KeyStore 신규 생성 완료.");
logger.warn("HsmManager] KeyStore 재로드 완료.");
logAliases(ks);
} }
private void logAliases(KeyStore ks) throws Exception { logKeyStore(keyStore);
java.util.Enumeration<String> aliases = ks.aliases();
StringBuilder aliasList = new StringBuilder(); } catch (Exception e) {
while (aliases.hasMoreElements()) { logger.warn("HsmManager] KeyStore 재로드 실패:" + e.getMessage(), e);
if (aliasList.length() > 0) aliasList.append(", "); logger.warn("HsmManager] Provider 전체 재초기화를 시도합니다.");
aliasList.append(aliases.nextElement()); fullReinitialize();
}
}
/**
* 세션 누적, 네트워크 단절 등으로 일반 재로드가 더 이상 복구되지 않을 때
* 기존 세션을 정리하고 Provider 를 완전히 새로 생성한다.
*
* 신규 Provider/KeyStore 준비가 완전히 성공한 후에만 기존 Provider 를 제거하고 교체한다.
* 재초기화 중 예외가 발생하면 기존 Provider 와 keyStore 를 그대로 유지한다
* (서비스 중단보다 마지막 정상 상태 보존을 우선).
*/
private void fullReinitialize() throws Exception {
Provider oldProvider = this.pkcs11Provider;
try {
// 1. 신규 Provider 인스턴스 생성 (Security 미등록 상태)
String configContent = EncryptionManager.getInstance()
.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG));
Provider newProvider = createProvider(configContent.trim().replace("\\n", "\n"));
// 2. 신규 Provider 로 KeyStore 로드 테스트
// KeyStore.getInstance(type, providerInstance) 는 Security 등록 없이도 동작하므로
// 여기서 실패해도 oldProvider/keyStore 는 변경되지 않은 상태를 유지함
KeyStore ks = KeyStore.getInstance("PKCS11", newProvider);
ks.load(null, pin);
// 3. 신규 연결 성공 → 기존 Provider 정리 후 교체
if (oldProvider instanceof AuthProvider) {
try {
((AuthProvider) oldProvider).logout();
} catch (Exception logoutEx) {
logger.warn("HsmManager] 기존 세션 logout 실패(무시하고 진행): " + logoutEx.getMessage());
}
}
Security.removeProvider(oldProvider.getName());
Security.addProvider(newProvider);
this.pkcs11Provider = newProvider;
this.keyStore = ks;
logger.warn("HsmManager] Provider 전체 재초기화 성공.");
} catch (Exception e) {
logger.warn("HsmManager] Provider 전체 재초기화 실패. 이전 keyStore 를 그대로 유지합니다: " + e.getMessage(), e);
throw e;
} }
logger.warn("HsmManager] HSM 키 목록: [" + aliasList + "]");
} }
/** /**
@@ -246,6 +312,13 @@ public class HsmManager implements Lifecycle {
} }
if (pkcs11Provider != null) { if (pkcs11Provider != null) {
if (pkcs11Provider instanceof AuthProvider) {
try {
((AuthProvider) pkcs11Provider).logout();
} catch (Exception e) {
logger.warn("HsmManager] 종료 시 logout 실패(무시): " + e.getMessage());
}
}
Security.removeProvider(pkcs11Provider.getName()); Security.removeProvider(pkcs11Provider.getName());
} }
pkcs11Provider = null; pkcs11Provider = null;
@@ -286,4 +359,20 @@ public class HsmManager implements Lifecycle {
public boolean isReady() { public boolean isReady() {
return started && pkcs11Provider != null && keyStore != null; return started && pkcs11Provider != null && keyStore != null;
} }
/**
* isReady() 와 달리 실제 HSM 호출로 세션 생존 여부까지 확인하는 헬스체크.
* 모니터링/헬스체크 엔드포인트에서 사용을 권장한다.
*/
public boolean isHealthy() {
if (!isReady()) {
return false;
}
try {
logKeyStore(keyStore);
return true;
} catch (Exception e) {
return false;
}
}
} }