From 2240a0d604ae720018a23794d3be863114d3138d Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 1 Sep 2026 10:08:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?HSM=20=EC=84=A4=EC=A0=95=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=ED=99=94(PRIMARY/SECONDARY)=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HsmManager - PKCS11_CONFIG_SECONDARY / PIN_SECONDARY 프로퍼티 추가. PIN_SECONDARY 미설정 시 PIN 재사용, PKCS11_CONFIG_SECONDARY 미설정 시 후보가 1건이므로 기존과 동일하게 동작한다. - 기동/재연결 모두 PRIMARY -> SECONDARY 순으로 시도해 최초 성공분을 사용한다. 연결 성공 판정은 Provider 생성 + KeyStore.load + alias/getKey probe까지 포함하며, Security 등록 전에 검증하므로 실패해도 사용 중인 Provider/keyStore는 영향이 없다. - 주기적 재로드에서 SECONDARY로 동작 중이면 PRIMARY 재연결을 먼저 시도해 PRIMARY 복구 시 자동 복귀한다. PRIMARY 정상 동작 중에는 기존 keyStore 인스턴스에 다시 load() 하여 PKCS11 세션 누적 방지 로직을 유지한다. - PropertyChangeListener로 등록하여 HSM 프로퍼티 변경 시 다음 스케줄을 기다리지 않고 즉시 재적용한다. HSM 통신이 호출 스레드를 막지 않도록 스케줄러 스레드에 위임하며, RELOAD_INTERVAL_MINUTES 변경 시 스케줄을 재등록한다. - activeConfigName, failoverCount, lastReloadResult 등 모니터링 상태값을 노출한다. HsmCryptoService - 캐시 진단용 getCachedKeyInfos() / getCacheTtlMs() 추가. 키 원본은 노출하지 않는다. /manage/hsm - status(연결 설정/프로퍼티/alias/캐시 키), properties, keystore/aliases, cache/keys 조회와 reload, cache/clear 실행 API 추가. PIN 류 프로퍼티는 마스킹한다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ZMr6y9kc6Y6TWN3x4gpYg --- .../eai/common/hsm/HsmCachedKeyInfo.java | 32 + .../eai/common/hsm/HsmCryptoService.java | 50 ++ .../eactive/eai/common/hsm/HsmManager.java | 643 +++++++++++++++--- .../eai/manage/hsm/HsmStatusController.java | 82 +++ .../eactive/eai/manage/hsm/HsmStatusDTO.java | 78 +++ .../eai/manage/hsm/HsmStatusService.java | 147 ++++ 6 files changed, 945 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/eactive/eai/common/hsm/HsmCachedKeyInfo.java create mode 100644 src/main/java/com/eactive/eai/manage/hsm/HsmStatusController.java create mode 100644 src/main/java/com/eactive/eai/manage/hsm/HsmStatusDTO.java create mode 100644 src/main/java/com/eactive/eai/manage/hsm/HsmStatusService.java diff --git a/src/main/java/com/eactive/eai/common/hsm/HsmCachedKeyInfo.java b/src/main/java/com/eactive/eai/common/hsm/HsmCachedKeyInfo.java new file mode 100644 index 0000000..716cb6e --- /dev/null +++ b/src/main/java/com/eactive/eai/common/hsm/HsmCachedKeyInfo.java @@ -0,0 +1,32 @@ +package com.eactive.eai.common.hsm; + +import lombok.Data; + +/** + * HsmCryptoService 키 캐시의 진단 정보. 키 원본(바이트/인코딩)은 절대 포함하지 않고 + * alias, 종류, 알고리즘, 캐시 시각/만료 여부만 노출한다. + */ +@Data +public class HsmCachedKeyInfo { + + /** SECRET(대칭키) / PUBLIC(공개키) */ + String keyType; + + /** HSM KeyStore alias */ + String alias; + + /** AES, RSA 등 */ + String algorithm; + + /** 캐시에 등록된 시각 (epoch millis) */ + long cachedAt; + + /** 캐시 등록 후 경과 시간 (millis) */ + long ageMillis; + + /** CACHE_TTL_SEC 기준 만료 여부. 만료되어도 HSM 장애 시 fallback 으로 사용된다. */ + boolean expired; + + /** 키 길이(바이트). non-extractable 등으로 알 수 없으면 -1. 키 값 자체는 노출하지 않는다. */ + int keyLength; +} 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 c63aeb1..36f5054 100644 --- a/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java +++ b/src/main/java/com/eactive/eai/common/hsm/HsmCryptoService.java @@ -7,6 +7,10 @@ import java.security.PrivateKey; import java.security.Provider; import java.security.PublicKey; import java.security.cert.Certificate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import javax.annotation.PostConstruct; @@ -199,6 +203,52 @@ public class HsmCryptoService implements PropertyChangeListener { logger.warn("HsmCryptoService] 키 캐시 초기화 완료"); } + /** + * 현재 캐싱된 키 목록의 진단 정보를 반환합니다. 키 원본은 포함하지 않습니다. + * HSM 상태 조회 API(/manage/hsm/status)에서 사용합니다. + */ + public List getCachedKeyInfos() { + List infos = new ArrayList<>(); + + for (Map.Entry> entry : secretKeyCache.entrySet()) { + SecretKey key = entry.getValue().key; + byte[] encoded = (key == null) ? null : key.getEncoded(); + infos.add(toInfo("SECRET", entry.getKey(), entry.getValue(), + (key == null) ? null : key.getAlgorithm(), + (encoded == null) ? -1 : encoded.length)); + } + + for (Map.Entry> entry : publicKeyCache.entrySet()) { + PublicKey key = entry.getValue().key; + byte[] encoded = (key == null) ? null : key.getEncoded(); + infos.add(toInfo("PUBLIC", entry.getKey(), entry.getValue(), + (key == null) ? null : key.getAlgorithm(), + (encoded == null) ? -1 : encoded.length)); + } + + infos.sort(Comparator.comparing(HsmCachedKeyInfo::getKeyType) + .thenComparing(HsmCachedKeyInfo::getAlias)); + return infos; + } + + private HsmCachedKeyInfo toInfo(String keyType, String alias, CachedKey cached, + String algorithm, int keyLength) { + HsmCachedKeyInfo info = new HsmCachedKeyInfo(); + info.setKeyType(keyType); + info.setAlias(alias); + info.setAlgorithm(algorithm); + info.setCachedAt(cached.cachedAt); + info.setAgeMillis(System.currentTimeMillis() - cached.cachedAt); + info.setExpired(cached.isExpired()); + info.setKeyLength(keyLength); + return info; + } + + /** 현재 적용된 키 캐시 TTL(밀리초). HSM.CACHE_TTL_SEC 프로퍼티로 변경된다. */ + public long getCacheTtlMs() { + return CACHE_TTL_MS; + } + // ------------------------------------------------------------------------- // RSA // ------------------------------------------------------------------------- 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 55cd57e..54fc3f9 100644 --- a/src/main/java/com/eactive/eai/common/hsm/HsmManager.java +++ b/src/main/java/com/eactive/eai/common/hsm/HsmManager.java @@ -1,5 +1,7 @@ package com.eactive.eai.common.hsm; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; import java.io.ByteArrayInputStream; import java.io.File; import java.io.InputStream; @@ -12,6 +14,10 @@ import java.security.NoSuchAlgorithmException; import java.security.Provider; import java.security.Security; import java.security.UnrecoverableKeyException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -27,6 +33,7 @@ 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.PropGroupVO; import com.eactive.eai.common.property.PropManager; import com.eactive.eai.common.util.ApplicationContextProvider; import com.eactive.eai.common.util.Logger; @@ -35,40 +42,62 @@ import com.eactive.eai.common.util.Logger; * SafeNet ProtectServer HSM 연동 관리자 (JDK 8 / SunPKCS11) * * PropManager 그룹 "HSM" 에서 읽는 키: - * PKCS11_CONFIG - pkcs11.cfg 파일 내용 - * PIN - HSM 슬롯 PIN + * PKCS11_CONFIG - primary pkcs11.cfg 파일 내용 + * PIN - primary 슬롯 PIN + * PKCS11_CONFIG_SECONDARY - secondary pkcs11.cfg 파일 내용 (미설정 시 이중화 비활성) + * PIN_SECONDARY - secondary 슬롯 PIN (미설정 시 PIN 재사용) + * RELOAD_INTERVAL_MINUTES - KeyStore 주기적 재로드 간격(분) * * PKCS11_CONFIG 값 예시 (개행은 \n 으로 입력): * name = ProtectServer * library = /opt/safenet/protecttoolkit5/ptk/lib/libcryptoki.so * slot = 0 - * + * * name = SoftHSM * library = C:/SoftHSM2/lib/softhsm2-x64.dll * slotListIndex = 0 * * --------------------------------------------------------------------- - * [세션 누적 방지 / 회로차단 로직 추가] - * 기존 구현은 재로드마다 KeyStore.getInstance(...).load(null, pin) 을 새로 - * 호출하여 PKCS11 세션(C_OpenSession)이 계속 누적되고, 결국 HSM 파티션의 - * 최대 세션 수를 초과하면서 reload 가 영구적으로 실패하는 문제가 있었다. - * 이를 방지하기 위해: - * 1) 정상 상황에서는 "기존 keyStore 인스턴스"에 다시 load() 하여 세션 재사용 + * [설정 이중화 / primary 자동 복귀] + * 기동(init) 과 재연결 시 항상 primary -> secondary 순서로 연결을 시도하고, + * 최초로 성공한 설정을 사용한다. secondary 로 절체된 뒤에는 주기적 재로드마다 + * primary 재연결을 먼저 시도하므로 primary 가 복구되면 자동으로 되돌아온다. + * + * [세션 누적 방지 / 회로차단 로직] + * 재로드마다 KeyStore.getInstance(...).load(null, pin) 을 새로 호출하면 + * PKCS11 세션(C_OpenSession)이 계속 누적되고, 결국 HSM 파티션의 최대 세션 수를 + * 초과하면서 reload 가 영구적으로 실패한다. 이를 방지하기 위해: + * 1) primary 로 동작 중이고 설정도 그대로면 "기존 keyStore 인스턴스"에 다시 + * load() 하여 세션을 재사용한다 (= primary 우선 시도와 동일한 의미) * 2) 실제 키 조회(probe)로 세션이 살아있는지 검증 - * 3) 연속 실패가 임계치를 넘으면 Provider 자체를 logout 후 완전히 재생성 + * 3) 재로드가 실패하거나 프로퍼티가 변경되면 primary -> secondary 순서로 + * Provider 를 완전히 재생성 * 4) 재생성마저 실패하면 마지막으로 성공한 keyStore 를 유지 (서비스 연속성 우선) + * + * [프로퍼티 즉시 반영] + * PropManager 의 PropertyChangeListener 로 등록되어 있어, 관리 포털에서 HSM 그룹을 + * reload 하면 다음 스케줄을 기다리지 않고 즉시 설정을 다시 읽어 재연결을 시도한다. + * 이 경우에도 신규 연결이 완전히 성공한 뒤에만 keyStore 멤버변수를 교체한다. * --------------------------------------------------------------------- */ @Component -public class HsmManager implements Lifecycle { +public class HsmManager implements Lifecycle, PropertyChangeListener { 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 static final String PROP_CONFIG_SECONDARY = "PKCS11_CONFIG_SECONDARY"; + private static final String PROP_PIN_SECONDARY = "PIN_SECONDARY"; private static final String PROP_RELOAD_INTERVAL_MINUTES = "RELOAD_INTERVAL_MINUTES"; + /** 설정 구분자. HSM 상태 조회 API 응답에도 그대로 노출된다. */ + public static final String CONFIG_PRIMARY = "PRIMARY"; + public static final String CONFIG_SECONDARY = "SECONDARY"; + + private static final long DEFAULT_RELOAD_INTERVAL_MINUTES = 10; + private Provider pkcs11Provider; private volatile KeyStore keyStore; private boolean started; @@ -77,10 +106,23 @@ public class HsmManager implements Lifecycle { private volatile char[] pin; - // 재로드 주기 (분 단위). 필요시 PropManager로 외부화 가능. - private static long RELOAD_INTERVAL_MINUTES = 1; + /** 현재 연결에 사용 중인 설정. 미연결이면 null. */ + private volatile HsmConfig activeConfig; + + // ---- 모니터링용 상태값 (HsmStatusController 에서 조회) ---- + private volatile long activeSince; + private volatile long lastReloadAt; + private volatile String lastReloadResult; + private volatile String lastErrorMessage; + private volatile long lastErrorAt; + private volatile int reloadSuccessCount; + private volatile int reloadFailCount; + private volatile int failoverCount; + + private volatile long reloadIntervalMinutes = DEFAULT_RELOAD_INTERVAL_MINUTES; private ScheduledExecutorService scheduler; private ScheduledFuture reloadFuture; + private volatile boolean propListenerRegistered; private HsmManager() { } @@ -89,6 +131,116 @@ public class HsmManager implements Lifecycle { return ApplicationContextProvider.getContext().getBean(HsmManager.class); } + // ------------------------------------------------------------------------- + // 설정 후보 (primary / secondary) + // ------------------------------------------------------------------------- + + /** + * 한 번의 연결 시도에 필요한 설정 한 벌. PropManager 에서 읽어 복호화까지 마친 값이다. + */ + static final class HsmConfig { + + final String name; + final String configContent; + final char[] pin; + + HsmConfig(String name, String configContent, char[] pin) { + this.name = name; + this.configContent = configContent; + this.pin = pin; + } + + /** PropManager 값이 바뀌었는지 판단하기 위한 비교. */ + boolean sameAs(HsmConfig other) { + if (other == null) { + return false; + } + return name.equals(other.name) + && configContent.equals(other.configContent) + && Arrays.equals(pin, other.pin); + } + } + + /** + * 연결 시도 결과. Security 등록 전 상태이며, 완전히 성공한 경우에만 + * applyConnection() 을 통해 멤버변수로 승격된다. + */ + private static final class HsmConnection { + + final HsmConfig config; + final Provider provider; + final KeyStore keyStore; + + HsmConnection(HsmConfig config, Provider provider, KeyStore keyStore) { + this.config = config; + this.provider = provider; + this.keyStore = keyStore; + } + } + + /** + * PropManager 에서 매번 새로 읽어 primary -> secondary 순서의 연결 후보를 만든다. + * 스케줄러/프로퍼티 변경 이벤트 모두 이 메서드를 통하므로 변경된 값이 즉시 반영된다. + * PKCS11_CONFIG 가 비어 있는 후보는 제외한다. + */ + private List loadConfigCandidates() { + PropManager propManager = PropManager.getInstance(); + EncryptionManager encManager = EncryptionManager.getInstance(); + + List candidates = new ArrayList<>(); + + String primaryConfig = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_CONFIG)); + String primaryPin = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_PIN)); + if (isNotBlank(primaryConfig)) { + candidates.add(new HsmConfig(CONFIG_PRIMARY, normalizeConfig(primaryConfig), toPin(primaryPin))); + } + + String secondaryConfig = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_CONFIG_SECONDARY)); + if (isNotBlank(secondaryConfig)) { + // PIN_SECONDARY 미설정이면 primary PIN 을 재사용한다 (슬롯만 이중화하는 구성 지원) + String rawSecondaryPin = propManager.getProperty(GROUP_NAME, PROP_PIN_SECONDARY); + String secondaryPin = isNotBlank(rawSecondaryPin) ? decrypt(encManager, rawSecondaryPin) : primaryPin; + candidates.add(new HsmConfig(CONFIG_SECONDARY, normalizeConfig(secondaryConfig), toPin(secondaryPin))); + } + + return candidates; + } + + private static String decrypt(EncryptionManager encManager, String value) { + if (value == null) { + return null; + } + return encManager.decryptDBData(value); + } + + private static boolean isNotBlank(String value) { + return value != null && !value.trim().isEmpty(); + } + + private static String normalizeConfig(String configContent) { + return configContent.trim().replace("\\n", "\n"); + } + + private static char[] toPin(String pinStr) { + return (pinStr != null) ? pinStr.toCharArray() : null; + } + + private static HsmConfig findByName(List candidates, String name) { + if (name == null) { + return null; + } + for (HsmConfig candidate : candidates) { + if (name.equals(candidate.name)) { + return candidate; + } + } + return null; + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + @Override public void start() throws LifecycleException { if (started) { @@ -99,6 +251,7 @@ public class HsmManager implements Lifecycle { try { init(); startReloadScheduler(); + registerPropertyChangeListener(); } catch (Exception e) { throw new LifecycleException(ExceptionUtil.getErrorCode(e, "RECEAIHSM002")); } @@ -109,38 +262,133 @@ 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(); + applyReloadInterval(); - String configContent = encManager.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG)); - String pinStr = encManager.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_PIN)); - - if (configContent == null || configContent.trim().isEmpty()) { + List candidates = loadConfigCandidates(); + if (candidates.isEmpty()) { logger.warn("HsmManager] PKCS11_CONFIG 가 설정되지 않았습니다. HSM 초기화를 건너뜁니다."); return; } - pkcs11Provider = createProvider(configContent.trim().replace("\\n", "\n")); + HsmConnection connection = connectFirstAvailable(candidates); + applyConnection(connection); - // 이미 등록된 Provider 가 있으면 제거 후 재등록 - Provider existing = Security.getProvider(pkcs11Provider.getName()); - if (existing != null) { - Security.removeProvider(existing.getName()); - } - Security.addProvider(pkcs11Provider); - - keyStore = KeyStore.getInstance("PKCS11", pkcs11Provider); - this.pin = (pinStr != null) ? pinStr.toCharArray() : null; - keyStore.load(null, pin); - - logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName()); - - logKeyStore(this.keyStore); + logger.warn("HsmManager] 초기화 완료. config=" + connection.config.name + + ", Provider=" + connection.provider.getName()); } - private void logKeyStore(KeyStore keyStore) throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException { - java.util.Enumeration aliases = keyStore.aliases(); + /** + * RELOAD_INTERVAL_MINUTES 를 다시 읽어 적용한다. + * + * @return 값이 변경되어 스케줄 재등록이 필요하면 true + */ + private boolean applyReloadInterval() { + long newInterval = reloadIntervalMinutes; + try { + String value = PropManager.getInstance().getProperty(GROUP_NAME, PROP_RELOAD_INTERVAL_MINUTES, + String.valueOf(DEFAULT_RELOAD_INTERVAL_MINUTES)); + if (isNotBlank(value)) { + newInterval = Long.parseLong(value.trim()); + } + } catch (Exception e) { + logger.warn("HsmManager] " + PROP_RELOAD_INTERVAL_MINUTES + " 값이 올바르지 않아 기존 값(" + + reloadIntervalMinutes + "분)을 유지합니다: " + e.getMessage()); + return false; + } + + if (newInterval <= 0) { + newInterval = DEFAULT_RELOAD_INTERVAL_MINUTES; + } + if (newInterval == reloadIntervalMinutes) { + return false; + } + reloadIntervalMinutes = newInterval; + return true; + } + + // ------------------------------------------------------------------------- + // 연결 + // ------------------------------------------------------------------------- + + /** + * 후보 설정을 primary -> secondary 순서로 시도하여 최초로 성공한 연결을 반환한다. + * 각 후보는 Provider 생성 + KeyStore.load + 키 목록 조회(probe)까지 모두 성공해야 + * 정상으로 간주한다. 전부 실패하면 마지막 예외를 던진다. + */ + private HsmConnection connectFirstAvailable(List candidates) throws Exception { + Exception lastException = null; + + for (HsmConfig config : candidates) { + try { + HsmConnection connection = connect(config); + logger.warn("HsmManager] HSM 연결 성공. config=" + config.name + + ", Provider=" + connection.provider.getName()); + return connection; + } catch (Exception e) { + lastException = e; + recordError(config.name + " 설정 연결 실패: " + e.getMessage()); + logger.warn("HsmManager] HSM 연결 실패. config=" + config.name + " : " + e.getMessage(), e); + } + } + + if (lastException != null) { + throw lastException; + } + throw new HsmException("HSM 연결 후보 설정이 없습니다."); + } + + /** + * 단일 설정으로 Provider/KeyStore 를 새로 생성한다. Security 에는 아직 등록하지 않으므로 + * 여기서 실패해도 현재 사용 중인 Provider/keyStore 는 영향을 받지 않는다. + */ + private HsmConnection connect(HsmConfig config) throws Exception { + Provider provider = createProvider(config.configContent); + KeyStore ks = KeyStore.getInstance("PKCS11", provider); + ks.load(null, config.pin); + logKeyStore(ks); + return new HsmConnection(config, provider, ks); + } + + /** + * 신규 연결을 멤버변수로 승격한다. 기존 Provider 는 이 시점에서만 logout/제거된다. + */ + private void applyConnection(HsmConnection connection) { + Provider oldProvider = this.pkcs11Provider; + HsmConfig oldConfig = this.activeConfig; + + if (oldProvider != null && oldProvider != connection.provider) { + if (oldProvider instanceof AuthProvider) { + try { + ((AuthProvider) oldProvider).logout(); + } catch (Exception logoutEx) { + logger.warn("HsmManager] 기존 세션 logout 실패(무시하고 진행): " + logoutEx.getMessage()); + } + } + Security.removeProvider(oldProvider.getName()); + } + + // 동일 이름으로 이미 등록된 Provider 가 있으면 제거 후 재등록 + Provider registered = Security.getProvider(connection.provider.getName()); + if (registered != null && registered != connection.provider) { + Security.removeProvider(registered.getName()); + } + Security.addProvider(connection.provider); + + this.pkcs11Provider = connection.provider; + this.keyStore = connection.keyStore; + this.activeConfig = connection.config; + this.pin = connection.config.pin; + this.activeSince = System.currentTimeMillis(); + + if (oldConfig != null && !oldConfig.name.equals(connection.config.name)) { + failoverCount++; + logger.warn("HsmManager] HSM 설정 절체: " + oldConfig.name + " -> " + connection.config.name); + } + } + + 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(", "); @@ -157,7 +405,11 @@ public class HsmManager implements Lifecycle { } } logger.warn("HsmManager] HSM 키 목록: [" + aliasList + "]"); - } + } + + // ------------------------------------------------------------------------- + // 주기적 재로드 + // ------------------------------------------------------------------------- /** * 별도 스레드에서 주기적으로 KeyStore 를 다시 로드하여 @@ -172,12 +424,29 @@ public class HsmManager implements Lifecycle { reloadFuture = scheduler.scheduleWithFixedDelay( this::reloadKeyStoreSafely, - RELOAD_INTERVAL_MINUTES, - RELOAD_INTERVAL_MINUTES, + reloadIntervalMinutes, + reloadIntervalMinutes, TimeUnit.MINUTES ); - logger.warn("HsmManager] KeyStore 주기적 재로드 스케줄러 시작. interval=" + RELOAD_INTERVAL_MINUTES + "분"); + logger.warn("HsmManager] KeyStore 주기적 재로드 스케줄러 시작. interval=" + reloadIntervalMinutes + "분"); + } + + /** RELOAD_INTERVAL_MINUTES 변경 시 스케줄을 다시 등록한다. */ + private synchronized void rescheduleReload() { + if (scheduler == null || scheduler.isShutdown()) { + return; + } + if (reloadFuture != null) { + reloadFuture.cancel(false); + } + reloadFuture = scheduler.scheduleWithFixedDelay( + this::reloadKeyStoreSafely, + reloadIntervalMinutes, + reloadIntervalMinutes, + TimeUnit.MINUTES + ); + logger.warn("HsmManager] KeyStore 재로드 주기 변경 적용: " + reloadIntervalMinutes + "분"); } /** @@ -196,79 +465,181 @@ public class HsmManager implements Lifecycle { * KeyStore 를 다시 로드한다. 외부(getSecretKey 등)에서 키 미스 발생 시 * 즉시 재시도용으로 직접 호출할 수도 있다. * - * 세션 누적 방지를 위해 새 KeyStore 인스턴스를 만들지 않고, - * 기존 keyStore 객체에 다시 load() 하여 기존 PKCS11 세션을 재사용한다. - * 연속 실패가 임계치를 넘으면 Provider 자체를 재생성한다. + * 처리 순서: + * 1) PropManager 에서 설정을 매번 새로 읽는다 (변경값 즉시 반영) + * 2) 설정이 변경되었거나 아직 연결이 없으면 primary -> secondary 순으로 전체 재연결 + * 3) 현재 secondary 로 동작 중이면 primary 복귀를 먼저 시도 + * 4) 그 외에는 기존 keyStore 인스턴스에 다시 load() (PKCS11 세션 재사용). + * 실패하면 primary -> secondary 순으로 전체 재연결 + * + * 어느 경로든 신규 연결이 완전히 성공한 경우에만 keyStore 멤버변수를 교체한다. */ public synchronized void reloadKeyStoreIfNeeded() throws Exception { - if (pkcs11Provider == null) { - return; // HSM 비활성화 상태 + + List candidates = loadConfigCandidates(); + if (candidates.isEmpty()) { + logger.warn("HsmManager] PKCS11_CONFIG 가 설정되지 않아 재로드를 건너뜁니다."); + return; } + HsmConfig active = this.activeConfig; + HsmConfig currentCandidate = findByName(candidates, active == null ? null : active.name); + + // 2) 미연결 상태이거나 현재 사용 중인 설정값 자체가 변경된 경우 + if (active == null || pkcs11Provider == null || currentCandidate == null + || !currentCandidate.sameAs(active)) { + logger.warn("HsmManager] HSM 설정 변경 또는 미연결 상태 감지 → 전체 재연결을 수행합니다."); + reconnect(candidates); + return; + } + + // 3) secondary 로 동작 중이면 primary 복귀를 우선 시도 + HsmConfig preferred = candidates.get(0); + if (!preferred.name.equals(active.name)) { + try { + HsmConnection connection = connect(preferred); + applyConnection(connection); + recordReloadSuccess(preferred.name + " 설정으로 복귀 성공"); + logger.warn("HsmManager] " + preferred.name + " 설정으로 복귀했습니다."); + return; + } catch (Exception e) { + logger.warn("HsmManager] " + preferred.name + " 복귀 시도 실패. 현재 " + active.name + + " 설정을 유지합니다: " + e.getMessage()); + } + } + + // 4) 현재 연결 유지 재로드 (세션 재사용) try { if (keyStore != null) { keyStore.load(null, pin); - logger.warn("HsmManager] KeyStore 재로드 완료 (기존 세션 재사용)."); + logKeyStore(keyStore); + logger.warn("HsmManager] KeyStore 재로드 완료 (config=" + active.name + ", 기존 세션 재사용)."); } else { KeyStore ks = KeyStore.getInstance("PKCS11", pkcs11Provider); ks.load(null, pin); + logKeyStore(ks); this.keyStore = ks; - logger.warn("HsmManager] KeyStore 신규 생성 완료."); + logger.warn("HsmManager] KeyStore 신규 생성 완료 (config=" + active.name + ")."); } + recordReloadSuccess(active.name + " 설정 재로드 성공"); - logKeyStore(keyStore); - - } catch (Exception e) { + } catch (Exception e) { logger.warn("HsmManager] KeyStore 재로드 실패:" + e.getMessage(), e); - logger.warn("HsmManager] Provider 전체 재초기화를 시도합니다."); - fullReinitialize(); + logger.warn("HsmManager] 후보 설정(PRIMARY→SECONDARY) 전체 재연결을 시도합니다."); + reconnect(candidates); } } /** - * 세션 누적, 네트워크 단절 등으로 일반 재로드가 더 이상 복구되지 않을 때 - * 기존 세션을 정리하고 Provider 를 완전히 새로 생성한다. + * 세션 누적, 네트워크 단절, 설정 변경 등으로 일반 재로드가 더 이상 복구되지 않을 때 + * primary -> secondary 순서로 Provider 를 완전히 새로 생성한다. * * 신규 Provider/KeyStore 준비가 완전히 성공한 후에만 기존 Provider 를 제거하고 교체한다. - * 재초기화 중 예외가 발생하면 기존 Provider 와 keyStore 를 그대로 유지한다 + * 모든 후보가 실패하면 기존 Provider 와 keyStore 를 그대로 유지한다 * (서비스 중단보다 마지막 정상 상태 보존을 우선). */ - private void fullReinitialize() throws Exception { - Provider oldProvider = this.pkcs11Provider; + private void reconnect(List candidates) throws Exception { 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 전체 재초기화 성공."); - + HsmConnection connection = connectFirstAvailable(candidates); + applyConnection(connection); + recordReloadSuccess(connection.config.name + " 설정 재연결 성공"); + logger.warn("HsmManager] HSM 재연결 성공. config=" + connection.config.name); } catch (Exception e) { - logger.warn("HsmManager] Provider 전체 재초기화 실패. 이전 keyStore 를 그대로 유지합니다: " + e.getMessage(), e); + recordReloadFailure(e.getMessage()); + logger.warn("HsmManager] HSM 재연결 실패. 이전 keyStore 를 그대로 유지합니다: " + e.getMessage(), e); throw e; } } + private void recordReloadSuccess(String detail) { + lastReloadAt = System.currentTimeMillis(); + lastReloadResult = "SUCCESS - " + detail; + reloadSuccessCount++; + } + + private void recordReloadFailure(String detail) { + lastReloadAt = System.currentTimeMillis(); + lastReloadResult = "FAIL - " + detail; + reloadFailCount++; + recordError(detail); + } + + private void recordError(String message) { + lastErrorMessage = message; + lastErrorAt = System.currentTimeMillis(); + } + + // ------------------------------------------------------------------------- + // PropManager 변경 즉시 반영 + // ------------------------------------------------------------------------- + + private void registerPropertyChangeListener() { + if (propListenerRegistered) { + return; + } + try { + PropManager.getInstance().addPropertyChangeListener(this); + propListenerRegistered = true; + logger.warn("HsmManager] PropManager PropertyChangeListener 등록 완료"); + } catch (Exception e) { + logger.warn("HsmManager] PropManager PropertyChangeListener 등록 실패(주기적 재로드로 대체): " + + e.getMessage()); + } + } + + private void unregisterPropertyChangeListener() { + if (!propListenerRegistered) { + return; + } + try { + PropManager.getInstance().removePropertyChangeListener(this); + } catch (Exception e) { + logger.warn("HsmManager] PropManager PropertyChangeListener 해제 실패(무시): " + e.getMessage()); + } + propListenerRegistered = false; + } + + /** + * 관리 포털에서 PropManager.reload("HSM") 또는 setProperty 를 호출하면 수신된다. + * 재로드 자체는 HSM 통신을 수반하므로 호출 스레드를 막지 않도록 스케줄러 스레드에 위임한다. + */ + @Override + public void propertyChange(PropertyChangeEvent evt) { + if (!isHsmGroupEvent(evt)) { + return; + } + + if (applyReloadInterval()) { + rescheduleReload(); + } + + ScheduledExecutorService currentScheduler = this.scheduler; + if (currentScheduler == null || currentScheduler.isShutdown()) { + return; + } + logger.warn("HsmManager] HSM 프로퍼티 변경 감지 → 설정 재적용을 요청합니다."); + currentScheduler.execute(this::reloadKeyStoreSafely); + } + + /** + * PropManager 는 reload(group) 시 propertyName 에 그룹명을, setProperty 시 키명을 담고 + * source 에는 항상 해당 그룹의 PropGroupVO 를 담는다. 두 경우를 모두 인식한다. + */ + private boolean isHsmGroupEvent(PropertyChangeEvent evt) { + if (evt == null) { + return false; + } + if (GROUP_NAME.equals(evt.getPropertyName())) { + return true; + } + Object source = evt.getSource(); + return (source instanceof PropGroupVO) && GROUP_NAME.equals(((PropGroupVO) source).getName()); + } + + // ------------------------------------------------------------------------- + // Provider 생성 + // ------------------------------------------------------------------------- + /** * JDK 버전에 따라 SunPKCS11 Provider 를 생성한다. * @@ -304,6 +675,8 @@ public class HsmManager implements Lifecycle { } lifecycle.fireLifecycleEvent(STOPING_EVENT, this); + unregisterPropertyChangeListener(); + if (reloadFuture != null) { reloadFuture.cancel(false); } @@ -323,6 +696,8 @@ public class HsmManager implements Lifecycle { } pkcs11Provider = null; keyStore = null; + activeConfig = null; + activeSince = 0; started = false; lifecycle.fireLifecycleEvent(STOPPED_EVENT, this); @@ -375,4 +750,98 @@ public class HsmManager implements Lifecycle { return false; } } -} \ No newline at end of file + + // ------------------------------------------------------------------------- + // 모니터링용 조회 (HsmStatusController) + // ------------------------------------------------------------------------- + + /** 현재 연결에 사용 중인 설정 이름(PRIMARY/SECONDARY). 미연결이면 null. */ + public String getActiveConfigName() { + HsmConfig config = this.activeConfig; + return (config == null) ? null : config.name; + } + + /** 현재 secondary 설정으로 절체된 상태인지 여부. */ + public boolean isUsingSecondaryConfig() { + return CONFIG_SECONDARY.equals(getActiveConfigName()); + } + + /** 현재 연결에 실제로 사용된 pkcs11.cfg 내용. PIN 은 포함되지 않는다. */ + public String getActiveConfigContent() { + HsmConfig config = this.activeConfig; + return (config == null) ? null : config.configContent; + } + + /** + * 현재 PropManager 값 기준으로 실제 적용될 pkcs11.cfg 내용을 후보별로 반환한다. + * PIN 은 포함하지 않는다. (설정이름 -> cfg 내용, primary 우선순) + */ + public java.util.Map getResolvedConfigContents() { + java.util.Map contents = new java.util.LinkedHashMap<>(); + try { + for (HsmConfig config : loadConfigCandidates()) { + contents.put(config.name, config.configContent); + } + } catch (Exception e) { + logger.warn("HsmManager] 연결 후보 설정 조회 실패: " + e.getMessage()); + } + return contents; + } + + /** 현재 KeyStore 의 alias 목록. HSM 통신이 발생한다. */ + public List getKeyAliases() throws Exception { + KeyStore ks = this.keyStore; + if (ks == null) { + return Collections.emptyList(); + } + List aliases = new ArrayList<>(); + java.util.Enumeration e = ks.aliases(); + while (e.hasMoreElements()) { + aliases.add(e.nextElement()); + } + return aliases; + } + + public String getProviderName() { + Provider provider = this.pkcs11Provider; + return (provider == null) ? null : provider.getName(); + } + + public long getReloadIntervalMinutes() { + return reloadIntervalMinutes; + } + + /** 현재 설정으로 연결된 시각(epoch millis). 미연결이면 0. */ + public long getActiveSince() { + return activeSince; + } + + public long getLastReloadAt() { + return lastReloadAt; + } + + public String getLastReloadResult() { + return lastReloadResult; + } + + public String getLastErrorMessage() { + return lastErrorMessage; + } + + public long getLastErrorAt() { + return lastErrorAt; + } + + public int getReloadSuccessCount() { + return reloadSuccessCount; + } + + public int getReloadFailCount() { + return reloadFailCount; + } + + /** primary <-> secondary 절체가 발생한 횟수. */ + public int getFailoverCount() { + return failoverCount; + } +} diff --git a/src/main/java/com/eactive/eai/manage/hsm/HsmStatusController.java b/src/main/java/com/eactive/eai/manage/hsm/HsmStatusController.java new file mode 100644 index 0000000..34e6331 --- /dev/null +++ b/src/main/java/com/eactive/eai/manage/hsm/HsmStatusController.java @@ -0,0 +1,82 @@ +package com.eactive.eai.manage.hsm; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Callable; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * HSM 연동 현황 조회 API. (ToolsController 와 동일한 응답 형태) + * + * PIN 등 비밀값과 키 원본은 응답에 포함하지 않는다. + * + * GET /manage/hsm/status → 연결 설정(PRIMARY/SECONDARY), 적용 프로퍼티, + * KeyStore alias, 캐싱된 키 목록 등 전체 현황 + * ?healthCheck=true → 실제 HSM 통신으로 세션 생존까지 확인 + * GET /manage/hsm/properties → HSM 프로퍼티 그룹의 현재 값 (PIN 류 마스킹) + * GET /manage/hsm/keystore/aliases → 현재 KeyStore 의 alias 목록 (HSM 통신 발생) + * GET /manage/hsm/cache/keys → HsmCryptoService 에 캐싱된 키 목록 + * POST /manage/hsm/reload → 즉시 재로드 (PRIMARY → SECONDARY 순 연결 시도) + * POST /manage/hsm/cache/clear → 키 캐시 초기화 + */ +@RestController +@RequestMapping("/manage/hsm") +public class HsmStatusController { + + private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8); + + @Autowired + private HsmStatusService hsmStatusService; + + @GetMapping("/status") + public ResponseEntity status( + @RequestParam(name = "healthCheck", required = false, defaultValue = "false") boolean healthCheck) { + return respond(() -> hsmStatusService.getStatus(healthCheck)); + } + + @GetMapping("/properties") + public ResponseEntity properties() { + return respond(() -> hsmStatusService.getMaskedProperties()); + } + + @GetMapping("/keystore/aliases") + public ResponseEntity keyStoreAliases() { + return respond(() -> hsmStatusService.getKeyAliases()); + } + + @GetMapping("/cache/keys") + public ResponseEntity cachedKeys() { + return respond(() -> hsmStatusService.getCachedKeys()); + } + + @PostMapping("/reload") + public ResponseEntity reload() { + return respond(() -> hsmStatusService.reloadNow()); + } + + @PostMapping("/cache/clear") + public ResponseEntity clearCache() { + return respond(() -> hsmStatusService.clearKeyCache()); + } + + private ResponseEntity respond(Callable action) { + Map result = new HashMap<>(); + try { + result.put("success", true); + result.put("data", action.call()); + } catch (Exception e) { + result.put("success", false); + result.put("message", e.getMessage()); + } + return ResponseEntity.ok().contentType(APPLICATION_JSON_UTF8).body(result); + } +} diff --git a/src/main/java/com/eactive/eai/manage/hsm/HsmStatusDTO.java b/src/main/java/com/eactive/eai/manage/hsm/HsmStatusDTO.java new file mode 100644 index 0000000..f90a0ed --- /dev/null +++ b/src/main/java/com/eactive/eai/manage/hsm/HsmStatusDTO.java @@ -0,0 +1,78 @@ +package com.eactive.eai.manage.hsm; + +import java.util.List; +import java.util.Map; + +import com.eactive.eai.common.hsm.HsmCachedKeyInfo; + +import lombok.Data; + +/** + * HSM 연동 현황 진단 정보. + * + * PIN 등 비밀값과 키 원본은 포함하지 않는다. PKCS11_CONFIG 는 라이브러리 경로/슬롯 정보만 + * 담고 있어 그대로 노출한다. + */ +@Data +public class HsmStatusDTO { + + // ---- 기동/연결 상태 ---- + + /** HsmManager Lifecycle 기동 여부 */ + boolean started; + + /** Provider 와 KeyStore 가 모두 준비된 상태인지 (HSM 통신 없음) */ + boolean ready; + + /** 실제 HSM 키 조회까지 성공하는지 (HSM 통신 발생, 조회 요청 시에만 채움) */ + Boolean healthy; + + /** 현재 연결에 사용 중인 설정: PRIMARY / SECONDARY / null(미연결) */ + String activeConfigName; + + /** secondary 로 절체된 상태인지 */ + boolean usingSecondaryConfig; + + /** 현재 등록된 SunPKCS11 Provider 이름 */ + String providerName; + + /** 현재 설정으로 연결된 시각 */ + String activeSince; + + // ---- 설정 ---- + + /** PropManager 기준 연결 후보 설정과 실제 적용될 pkcs11.cfg 내용 (primary 우선순) */ + Map resolvedConfigs; + + /** HSM 프로퍼티 그룹의 현재 값 (PIN 류는 마스킹) */ + Map properties; + + /** KeyStore 주기적 재로드 간격(분) */ + long reloadIntervalMinutes; + + // ---- 재로드 이력 ---- + + String lastReloadAt; + String lastReloadResult; + String lastErrorAt; + String lastErrorMessage; + int reloadSuccessCount; + int reloadFailCount; + + /** primary <-> secondary 절체 발생 횟수 */ + int failoverCount; + + // ---- 키 ---- + + /** 현재 KeyStore 의 alias 목록 (HSM 통신 발생) */ + List keyAliases; + + /** alias 조회 실패 시 사유. 성공이면 null */ + String keyAliasError; + + /** HsmCryptoService 에 캐싱된 키 목록 (키 원본 미포함) */ + List cachedKeys; + + /** 키 캐시 TTL(밀리초) */ + long cacheTtlMs; +} diff --git a/src/main/java/com/eactive/eai/manage/hsm/HsmStatusService.java b/src/main/java/com/eactive/eai/manage/hsm/HsmStatusService.java new file mode 100644 index 0000000..4dbf451 --- /dev/null +++ b/src/main/java/com/eactive/eai/manage/hsm/HsmStatusService.java @@ -0,0 +1,147 @@ +package com.eactive.eai.manage.hsm; + +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.eactive.eai.common.hsm.HsmCachedKeyInfo; +import com.eactive.eai.common.hsm.HsmCryptoService; +import com.eactive.eai.common.hsm.HsmManager; +import com.eactive.eai.common.property.PropManager; + +/** + * HSM 연동 현황 조회 서비스. + * + * HsmManager 의 연결 상태(primary/secondary), 적용 중인 HSM 프로퍼티, HsmCryptoService 의 + * 키 캐시 목록을 한 곳에 모아 진단용으로 제공한다. + */ +@Service +public class HsmStatusService { + + private static final String PROP_GROUP = "HSM"; + + /** 이 문자열이 포함된 프로퍼티 키의 값은 마스킹한다. */ + private static final String[] SECRET_KEY_TOKENS = { "PIN", "PASSWORD", "PASSWD", "SECRET" }; + + private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss"; + + @Autowired + private HsmCryptoService hsmCryptoService; + + /** + * @param includeHealthCheck true 이면 실제 HSM 통신으로 세션 생존까지 확인한다. + */ + public HsmStatusDTO getStatus(boolean includeHealthCheck) { + HsmManager hsmManager = HsmManager.getInstance(); + + HsmStatusDTO status = new HsmStatusDTO(); + status.setStarted(hsmManager.isStarted()); + status.setReady(hsmManager.isReady()); + status.setActiveConfigName(hsmManager.getActiveConfigName()); + status.setUsingSecondaryConfig(hsmManager.isUsingSecondaryConfig()); + status.setProviderName(hsmManager.getProviderName()); + status.setActiveSince(formatTime(hsmManager.getActiveSince())); + + status.setResolvedConfigs(hsmManager.getResolvedConfigContents()); + status.setProperties(getMaskedProperties()); + status.setReloadIntervalMinutes(hsmManager.getReloadIntervalMinutes()); + + status.setLastReloadAt(formatTime(hsmManager.getLastReloadAt())); + status.setLastReloadResult(hsmManager.getLastReloadResult()); + status.setLastErrorAt(formatTime(hsmManager.getLastErrorAt())); + status.setLastErrorMessage(hsmManager.getLastErrorMessage()); + status.setReloadSuccessCount(hsmManager.getReloadSuccessCount()); + status.setReloadFailCount(hsmManager.getReloadFailCount()); + status.setFailoverCount(hsmManager.getFailoverCount()); + + try { + status.setKeyAliases(hsmManager.getKeyAliases()); + } catch (Exception e) { + status.setKeyAliases(Collections.emptyList()); + status.setKeyAliasError(e.getMessage()); + } + + status.setCachedKeys(getCachedKeys()); + status.setCacheTtlMs(hsmCryptoService.getCacheTtlMs()); + + if (includeHealthCheck) { + status.setHealthy(Boolean.valueOf(hsmManager.isHealthy())); + } + + return status; + } + + /** HsmCryptoService 에 캐싱된 키 목록 (키 원본 미포함). */ + public List getCachedKeys() { + return hsmCryptoService.getCachedKeyInfos(); + } + + /** 현재 KeyStore 의 alias 목록. HSM 통신이 발생한다. */ + public List getKeyAliases() throws Exception { + return HsmManager.getInstance().getKeyAliases(); + } + + /** + * HSM 프로퍼티 그룹의 현재 값. PIN 등 비밀값은 마스킹한다. + * PropManager 에 저장된 원본(암호화 저장 시 암호문) 그대로이며, 실제 적용되는 + * 복호화 config 내용은 HsmStatusDTO.resolvedConfigs 에서 확인한다. + */ + public Map getMaskedProperties() { + Map masked = new TreeMap<>(); + try { + Properties properties = PropManager.getInstance().getProperties(PROP_GROUP); + for (String key : properties.stringPropertyNames()) { + masked.put(key, mask(key, properties.getProperty(key))); + } + } catch (Exception e) { + masked.put("_error", "HSM 프로퍼티 그룹 조회 실패: " + e.getMessage()); + } + return masked; + } + + /** + * 재로드를 즉시 수행한다. primary -> secondary 순으로 연결을 시도하며, + * 신규 연결이 완전히 성공한 경우에만 KeyStore 가 교체된다. + */ + public String reloadNow() throws Exception { + HsmManager hsmManager = HsmManager.getInstance(); + hsmManager.reloadKeyStoreIfNeeded(); + return "재로드 완료. activeConfig=" + hsmManager.getActiveConfigName() + + ", result=" + hsmManager.getLastReloadResult(); + } + + /** HsmCryptoService 키 캐시를 비운다. */ + public String clearKeyCache() { + hsmCryptoService.clearKeyCache(); + return "키 캐시 초기화 완료"; + } + + // ------------------------------------------------------------------------- + + private String mask(String key, String value) { + if (value == null) { + return null; + } + String upperKey = key.toUpperCase(); + for (String token : SECRET_KEY_TOKENS) { + if (upperKey.contains(token)) { + return "****(len=" + value.length() + ")"; + } + } + return value; + } + + private String formatTime(long epochMillis) { + if (epochMillis <= 0) { + return null; + } + return new SimpleDateFormat(DATE_FORMAT).format(new Date(epochMillis)); + } +} From 3c6c8d181d035f8f11e82d1feb152264de327fcf Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 1 Sep 2026 11:26:11 +0900 Subject: [PATCH 2/2] =?UTF-8?q?isNotBlank=20->=20StringUtils.isNotBlank?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/eactive/eai/common/hsm/HsmManager.java | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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 54fc3f9..0db1689 100644 --- a/src/main/java/com/eactive/eai/common/hsm/HsmManager.java +++ b/src/main/java/com/eactive/eai/common/hsm/HsmManager.java @@ -25,6 +25,7 @@ import java.util.concurrent.TimeUnit; import javax.crypto.SecretKey; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import com.eactive.eai.agent.encryption.EncryptionManager; @@ -191,15 +192,15 @@ public class HsmManager implements Lifecycle, PropertyChangeListener { String primaryConfig = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_CONFIG)); String primaryPin = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_PIN)); - if (isNotBlank(primaryConfig)) { + if (StringUtils.isNotBlank(primaryConfig)) { candidates.add(new HsmConfig(CONFIG_PRIMARY, normalizeConfig(primaryConfig), toPin(primaryPin))); } String secondaryConfig = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_CONFIG_SECONDARY)); - if (isNotBlank(secondaryConfig)) { + if (StringUtils.isNotBlank(secondaryConfig)) { // PIN_SECONDARY 미설정이면 primary PIN 을 재사용한다 (슬롯만 이중화하는 구성 지원) String rawSecondaryPin = propManager.getProperty(GROUP_NAME, PROP_PIN_SECONDARY); - String secondaryPin = isNotBlank(rawSecondaryPin) ? decrypt(encManager, rawSecondaryPin) : primaryPin; + String secondaryPin = StringUtils.isNotBlank(rawSecondaryPin) ? decrypt(encManager, rawSecondaryPin) : primaryPin; candidates.add(new HsmConfig(CONFIG_SECONDARY, normalizeConfig(secondaryConfig), toPin(secondaryPin))); } @@ -213,10 +214,6 @@ public class HsmManager implements Lifecycle, PropertyChangeListener { return encManager.decryptDBData(value); } - private static boolean isNotBlank(String value) { - return value != null && !value.trim().isEmpty(); - } - private static String normalizeConfig(String configContent) { return configContent.trim().replace("\\n", "\n"); } @@ -287,7 +284,7 @@ public class HsmManager implements Lifecycle, PropertyChangeListener { try { String value = PropManager.getInstance().getProperty(GROUP_NAME, PROP_RELOAD_INTERVAL_MINUTES, String.valueOf(DEFAULT_RELOAD_INTERVAL_MINUTES)); - if (isNotBlank(value)) { + if (StringUtils.isNotBlank(value)) { newInterval = Long.parseLong(value.trim()); } } catch (Exception e) {