322 lines
14 KiB
Java
322 lines
14 KiB
Java
package com.eactive.eai.common.hsm;
|
|
|
|
import java.beans.PropertyChangeEvent;
|
|
import java.beans.PropertyChangeListener;
|
|
import java.security.KeyStore;
|
|
import java.security.PrivateKey;
|
|
import java.security.Provider;
|
|
import java.security.PublicKey;
|
|
import java.security.cert.Certificate;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
|
|
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;
|
|
|
|
import com.eactive.eai.common.property.PropManager;
|
|
import com.eactive.eai.common.util.Logger;
|
|
|
|
/**
|
|
* SafeNet ProtectServer HSM 암복호화 서비스 (JDK 8)
|
|
*
|
|
* [키 조회]
|
|
* getPublicKey - 공개키 반환 (외부 노출). 최초 1회만 HSM 통신, 이후 캐시 반환.
|
|
* getSecretKey - AES 대칭키 반환 (CKA_EXTRACTABLE=true 필요). 최초 1회만 HSM 통신, 이후 캐시 반환.
|
|
*
|
|
* [암복호화]
|
|
* encryptRsa - 공개키로 JVM 소프트웨어 암호화 (HSM 불필요)
|
|
* decryptRsa - alias 기반, HSM 내부 복호화 (매 호출마다 HSM 통신 발생 - 불가피)
|
|
* encryptAes - SecretKey 객체로 AES/CBC 암호화
|
|
* decryptAes - SecretKey 객체로 AES/CBC 복호화
|
|
*/
|
|
@Service
|
|
public class HsmCryptoService implements PropertyChangeListener {
|
|
|
|
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";
|
|
|
|
private static final String PROP_GROUP = "HSM";
|
|
private static final String PROP_CACHE_RELOAD_YN = "CACHE_RELOAD_YN";
|
|
|
|
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;
|
|
final long cachedAt;
|
|
|
|
CachedKey(T key) {
|
|
this.key = key;
|
|
this.cachedAt = System.currentTimeMillis();
|
|
}
|
|
|
|
boolean isExpired() {
|
|
return System.currentTimeMillis() - cachedAt > CACHE_TTL_MS;
|
|
}
|
|
}
|
|
|
|
// HSM 통신 최소화: 최초 1회 조회 후 JVM 메모리에 캐싱
|
|
private final ConcurrentHashMap<String, CachedKey<SecretKey>> secretKeyCache = new ConcurrentHashMap<>();
|
|
private final ConcurrentHashMap<String, CachedKey<PublicKey>> publicKeyCache = new ConcurrentHashMap<>();
|
|
|
|
@Autowired
|
|
private PropManager propManager;
|
|
|
|
@PostConstruct
|
|
public void registerPropertyChangeListener() {
|
|
propManager.addPropertyChangeListener(this);
|
|
logger.warn("HsmCryptoService] PropManager PropertyChangeListener 등록 완료");
|
|
}
|
|
|
|
/**
|
|
* 관리포털에서 PropManager.reload("HSM") 호출 시 이벤트를 수신한다.
|
|
* HSM.CACHE_RELOAD_YN = Y 이면 키 캐시를 초기화한다.
|
|
*/
|
|
@Override
|
|
public void propertyChange(PropertyChangeEvent evt) {
|
|
if (!PROP_GROUP.equals(evt.getPropertyName())) {
|
|
return;
|
|
}
|
|
String reloadYn = propManager.getProperty(PROP_GROUP, PROP_CACHE_RELOAD_YN, "N");
|
|
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로 외부화
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 키 조회 (외부 노출)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* HSM KeyStore 에서 공개키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다.
|
|
* HSM 장애 시 만료된 캐시가 있으면 그것을 반환하여 서비스 연속성을 유지합니다.
|
|
*/
|
|
public PublicKey getPublicKey(String keyAlias) throws HsmException {
|
|
// 1. 유효한 캐시 즉시 반환 (HSM 상태 무관)
|
|
CachedKey<PublicKey> cached = publicKeyCache.get(keyAlias);
|
|
if (cached != null && !cached.isExpired()) {
|
|
return cached.key;
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// 1. 유효한 캐시 즉시 반환 (HSM 상태 무관)
|
|
CachedKey<SecretKey> cached = secretKeyCache.get(keyAlias);
|
|
if (cached != null && !cached.isExpired()) {
|
|
return cached.key;
|
|
}
|
|
|
|
// 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 키 교체 후 재로드가 필요할 때 호출합니다. */
|
|
public void clearKeyCache() {
|
|
secretKeyCache.clear();
|
|
publicKeyCache.clear();
|
|
logger.warn("HsmCryptoService] 키 캐시 초기화 완료");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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 암호화.
|
|
* extractable 키: provider=null 로 JVM 소프트웨어 암호화.
|
|
* non-extractable 키: provider 에 pkcs11Provider 를 전달해야 합니다.
|
|
*
|
|
* @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키)
|
|
* @param iv 초기화 벡터 (16바이트)
|
|
* @param plaintext 평문 바이트
|
|
* @param provider null 이면 JVM 기본 Provider 사용
|
|
*/
|
|
public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext, Provider provider) throws HsmException {
|
|
try {
|
|
Cipher cipher = (provider != null)
|
|
? Cipher.getInstance(AES_ALGORITHM, provider)
|
|
: 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 암호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */
|
|
public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext) throws HsmException {
|
|
return encryptAes(secretKey, iv, plaintext, null);
|
|
}
|
|
|
|
/**
|
|
* AES/CBC/PKCS5Padding 복호화.
|
|
* extractable 키: provider=null 로 JVM 소프트웨어 복호화.
|
|
* non-extractable 키: provider 에 pkcs11Provider 를 전달해야 합니다.
|
|
*
|
|
* @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키)
|
|
* @param iv 초기화 벡터 (16바이트)
|
|
* @param ciphertext 암호문 바이트
|
|
* @param provider null 이면 JVM 기본 Provider 사용
|
|
*/
|
|
public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext, Provider provider) throws HsmException {
|
|
try {
|
|
Cipher cipher = (provider != null)
|
|
? Cipher.getInstance(AES_ALGORITHM, provider)
|
|
: 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);
|
|
}
|
|
}
|
|
|
|
/** AES 복호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */
|
|
public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext) throws HsmException {
|
|
return decryptAes(secretKey, iv, ciphertext, null);
|
|
}
|
|
|
|
}
|