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 5dab96d..c63aeb1 100644 --- a/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java +++ b/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java @@ -13,6 +13,7 @@ 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; @@ -44,7 +45,8 @@ public class HsmCryptoService implements PropertyChangeListener { private static final String PROP_GROUP = "HSM"; 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 { final T key; @@ -86,6 +88,9 @@ public class HsmCryptoService implements PropertyChangeListener { 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로 외부화 } // ------------------------------------------------------------------------- @@ -94,55 +99,97 @@ public class HsmCryptoService implements PropertyChangeListener { /** * HSM KeyStore 에서 공개키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다. + * HSM 장애 시 만료된 캐시가 있으면 그것을 반환하여 서비스 연속성을 유지합니다. */ public PublicKey getPublicKey(String keyAlias) throws HsmException { - checkReady(); + // 1. 유효한 캐시 즉시 반환 (HSM 상태 무관) CachedKey cached = publicKeyCache.get(keyAlias); if (cached != null && !cached.isExpired()) { return cached.key; } - try { - Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias); - if (cert == null) { - throw new HsmException("인증서를 찾을 수 없습니다. alias=" + keyAlias); + + // 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()); } - 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) { - throw new HsmException("공개키 조회 실패: " + e.getMessage(), e); } + + // 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 { - checkReady(); + // 1. 유효한 캐시 즉시 반환 (HSM 상태 무관) CachedKey cached = secretKeyCache.get(keyAlias); if (cached != null && !cached.isExpired()) { return cached.key; } - 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; - secretKeyCache.put(keyAlias, new CachedKey<>(secretKey)); - logger.warn("HsmCryptoService] 대칭키 캐시 등록(갱신): alias=" + keyAlias); - return secretKey; - } catch (HsmException e) { - throw e; - } catch (Exception e) { - throw new HsmException("AES 키 조회 실패: " + e.getMessage(), e); + // 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 키 교체 후 재로드가 필요할 때 호출합니다. */ @@ -271,13 +318,4 @@ public class HsmCryptoService implements PropertyChangeListener { return decryptAes(secretKey, iv, ciphertext, null); } - // ------------------------------------------------------------------------- - // 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/HsmManager.java b/src/main/java/com/eactive/eai/common/hsm/HsmManager.java index 8409876..55cd57e 100644 --- a/src/main/java/com/eactive/eai/common/hsm/HsmManager.java +++ b/src/main/java/com/eactive/eai/common/hsm/HsmManager.java @@ -5,10 +5,13 @@ import java.io.File; import java.io.InputStream; import java.lang.reflect.Method; import java.nio.file.Files; +import java.security.AuthProvider; import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; import java.security.Provider; import java.security.Security; -import java.util.Base64; +import java.security.UnrecoverableKeyException; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -43,6 +46,18 @@ import com.eactive.eai.common.util.Logger; * name = SoftHSM * library = C:/SoftHSM2/lib/softhsm2-x64.dll * slotListIndex = 0 + * + * --------------------------------------------------------------------- + * [세션 누적 방지 / 회로차단 로직 추가] + * 기존 구현은 재로드마다 KeyStore.getInstance(...).load(null, pin) 을 새로 + * 호출하여 PKCS11 세션(C_OpenSession)이 계속 누적되고, 결국 HSM 파티션의 + * 최대 세션 수를 초과하면서 reload 가 영구적으로 실패하는 문제가 있었다. + * 이를 방지하기 위해: + * 1) 정상 상황에서는 "기존 keyStore 인스턴스"에 다시 load() 하여 세션 재사용 + * 2) 실제 키 조회(probe)로 세션이 살아있는지 검증 + * 3) 연속 실패가 임계치를 넘으면 Provider 자체를 logout 후 완전히 재생성 + * 4) 재생성마저 실패하면 마지막으로 성공한 keyStore 를 유지 (서비스 연속성 우선) + * --------------------------------------------------------------------- */ @Component public class HsmManager implements Lifecycle { @@ -53,7 +68,7 @@ public class HsmManager implements Lifecycle { private static final String PROP_CONFIG = "PKCS11_CONFIG"; private static final String PROP_PIN = "PIN"; private static final String PROP_RELOAD_INTERVAL_MINUTES = "RELOAD_INTERVAL_MINUTES"; - + private Provider pkcs11Provider; private volatile KeyStore keyStore; private boolean started; @@ -62,11 +77,11 @@ public class HsmManager implements Lifecycle { private volatile char[] pin; - // 재로드 주기 (분 단위). 필요시 PropManager로 외부화 가능. + // 재로드 주기 (분 단위). 필요시 PropManager로 외부화 가능. private static long RELOAD_INTERVAL_MINUTES = 1; private ScheduledExecutorService scheduler; private ScheduledFuture reloadFuture; - + private HsmManager() { } @@ -93,11 +108,11 @@ public class HsmManager implements Lifecycle { } private void init() throws Exception { - - String reloadIntervalStr = PropManager.getInstance().getProperty(GROUP_NAME, PROP_RELOAD_INTERVAL_MINUTES, "10"); - RELOAD_INTERVAL_MINUTES = Long.parseLong(reloadIntervalStr); - EncryptionManager encManager = EncryptionManager.getInstance(); - + + String reloadIntervalStr = PropManager.getInstance().getProperty(GROUP_NAME, PROP_RELOAD_INTERVAL_MINUTES, "10"); + RELOAD_INTERVAL_MINUTES = Long.parseLong(reloadIntervalStr); + EncryptionManager encManager = EncryptionManager.getInstance(); + String configContent = encManager.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG)); String pinStr = encManager.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_PIN)); @@ -121,30 +136,31 @@ public class HsmManager implements Lifecycle { logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName()); - java.util.Enumeration aliases = keyStore.aliases(); + logKeyStore(this.keyStore); + } + + private void logKeyStore(KeyStore keyStore) throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException { + java.util.Enumeration aliases = keyStore.aliases(); StringBuilder aliasList = new StringBuilder(); while (aliases.hasMoreElements()) { if (aliasList.length() > 0) aliasList.append(", "); - + String alias = aliases.nextElement(); aliasList.append(alias); - + java.security.Key key = keyStore.getKey(alias, null); if (key instanceof SecretKey) { - SecretKey secretKey = (SecretKey) key; - if(secretKey.getEncoded() != null) { - String encBase64 = Base64.getEncoder().encodeToString(secretKey.getEncoded()); - logger.debug("HsmManager] HSM - {} : [{}]", alias, encBase64); - } else { - logger.debug("HsmManager] HSM - secretKey null {} : [{}]", alias, secretKey); - } + SecretKey secretKey = (SecretKey) key; + if (secretKey.getEncoded() == null) { + logger.warn("HsmManager] HSM - secretKey null {} : [{}]", alias, secretKey); + } } } logger.warn("HsmManager] HSM 키 목록: [" + aliasList + "]"); - } - + } + /** - * 별도 스레드에서 주기적으로 KeyStore.load() 를 다시 호출하여 + * 별도 스레드에서 주기적으로 KeyStore 를 다시 로드하여 * HSM 에 새로 생성/추가된 키를 인식하도록 한다. */ private void startReloadScheduler() { @@ -172,35 +188,85 @@ public class HsmManager implements Lifecycle { try { reloadKeyStoreIfNeeded(); } catch (Throwable t) { - logger.warn("HsmManager] KeyStore 주기적 재로드 실패: " + t.getMessage()); + logger.warn("HsmManager] KeyStore 주기적 재로드 실패: " + t.getMessage(), t); } } /** * KeyStore 를 다시 로드한다. 외부(getSecretKey 등)에서 키 미스 발생 시 * 즉시 재시도용으로 직접 호출할 수도 있다. + * + * 세션 누적 방지를 위해 새 KeyStore 인스턴스를 만들지 않고, + * 기존 keyStore 객체에 다시 load() 하여 기존 PKCS11 세션을 재사용한다. + * 연속 실패가 임계치를 넘으면 Provider 자체를 재생성한다. */ public synchronized void reloadKeyStoreIfNeeded() throws Exception { if (pkcs11Provider == null) { return; // HSM 비활성화 상태 } - KeyStore ks = KeyStore.getInstance("PKCS11", pkcs11Provider); - ks.load(null, pin); - this.keyStore = ks; // volatile 필드 교체 - 다른 스레드에서 즉시 가시성 확보 + try { + if (keyStore != null) { + keyStore.load(null, pin); + logger.warn("HsmManager] KeyStore 재로드 완료 (기존 세션 재사용)."); + } else { + KeyStore ks = KeyStore.getInstance("PKCS11", pkcs11Provider); + ks.load(null, pin); + this.keyStore = ks; + logger.warn("HsmManager] KeyStore 신규 생성 완료."); + } - logger.warn("HsmManager] KeyStore 재로드 완료."); - logAliases(ks); - } - - private void logAliases(KeyStore ks) throws Exception { - java.util.Enumeration aliases = ks.aliases(); - StringBuilder aliasList = new StringBuilder(); - while (aliases.hasMoreElements()) { - if (aliasList.length() > 0) aliasList.append(", "); - aliasList.append(aliases.nextElement()); + logKeyStore(keyStore); + + } catch (Exception e) { + logger.warn("HsmManager] KeyStore 재로드 실패:" + e.getMessage(), e); + logger.warn("HsmManager] Provider 전체 재초기화를 시도합니다."); + 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 instanceof AuthProvider) { + try { + ((AuthProvider) pkcs11Provider).logout(); + } catch (Exception e) { + logger.warn("HsmManager] 종료 시 logout 실패(무시): " + e.getMessage()); + } + } Security.removeProvider(pkcs11Provider.getName()); } pkcs11Provider = null; @@ -286,4 +359,20 @@ public class HsmManager implements Lifecycle { public boolean isReady() { 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; + } + } +} \ No newline at end of file