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.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<T> {
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<PublicKey> 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<SecretKey> 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 가 초기화되지 않았습니다.");
}
}
}