bf1135f9ad
- CryptoModuleExtension 인터페이스: BC 프로바이더, AAD, 편의 메서드 오버로드 추가 - AESCryptoModuleExtension: CBC/GCM/ECB/FF1 지원, GCM 랜덤 nonce, AAD 처리 - ARIACryptoModuleExtension: ARIA 알고리즘 지원 - CryptoModuleConfigVO: JPA Entity 분리를 위한 Lombok VO - CryptoModuleManager: VO 패턴 적용, FQCN 기반 전략 동적 로딩(Class.forName) - CryptoModuleService: STATIC/DYNAMIC × AAD 조합 8가지 오버로드 - CryptoModuleConfigMapper: MapStruct Entity→VO 매퍼 - KeyDerivationStrategy 인터페이스 및 DerivedKey - HsmContextXorKeyDerivationStrategy: HSM 마스터키 XOR 컨텍스트값 전략 - ReloadCryptoModuleCommand: 설정 런타임 재로드 커맨드 - build.gradle: BouncyCastle bcprov-jdk15on:1.70 의존성 추가 - 단위 테스트 전체 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
392 lines
16 KiB
Java
392 lines
16 KiB
Java
package com.eactive.eai.common.security;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
import static org.mockito.Mockito.*;
|
|
|
|
import java.lang.reflect.Constructor;
|
|
import java.lang.reflect.Field;
|
|
import java.lang.reflect.Method;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.Arrays;
|
|
import java.util.Collections;
|
|
import java.util.HashMap;
|
|
import java.util.Map;
|
|
import java.util.Optional;
|
|
|
|
import javax.crypto.SecretKey;
|
|
import javax.crypto.spec.SecretKeySpec;
|
|
|
|
import org.junit.jupiter.api.AfterAll;
|
|
import org.junit.jupiter.api.BeforeAll;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.DisplayName;
|
|
import org.junit.jupiter.api.MethodOrderer;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.TestMethodOrder;
|
|
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
|
|
import org.springframework.context.support.GenericApplicationContext;
|
|
|
|
import com.eactive.eai.common.hsm.HsmCryptoService;
|
|
import com.eactive.eai.common.security.loader.CryptoModuleConfigLoader;
|
|
import com.eactive.eai.common.security.mapper.CryptoModuleConfigMapper;
|
|
import com.eactive.eai.common.util.ApplicationContextProvider;
|
|
import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig;
|
|
|
|
/**
|
|
* CryptoModuleManager 단위 테스트
|
|
* DB / 실 Spring 컨텍스트 없이 Mock으로 동작한다.
|
|
*/
|
|
@TestMethodOrder(MethodOrderer.DisplayName.class)
|
|
class CryptoModuleManagerTest {
|
|
|
|
private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
|
|
private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8);
|
|
private static final String ENC_KEY_HEX = toHex(KEY_128);
|
|
private static final String IV_HEX = toHex(IV_16);
|
|
|
|
private static GenericApplicationContext ctx;
|
|
private static CryptoModuleConfigLoader mockLoader;
|
|
private static HsmCryptoService mockHsmCryptoService;
|
|
private static CryptoModuleManager manager;
|
|
|
|
@BeforeAll
|
|
static void setUpClass() throws Exception {
|
|
mockLoader = mock(CryptoModuleConfigLoader.class);
|
|
mockHsmCryptoService = mock(HsmCryptoService.class);
|
|
|
|
SecretKey masterKey = new SecretKeySpec(KEY_128, "AES");
|
|
when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey);
|
|
when(mockLoader.findAll()).thenReturn(Collections.emptyList());
|
|
|
|
Constructor<CryptoModuleManager> ctor = CryptoModuleManager.class.getDeclaredConstructor();
|
|
ctor.setAccessible(true);
|
|
manager = ctor.newInstance();
|
|
|
|
ctx = new GenericApplicationContext();
|
|
ctx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader);
|
|
ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsmCryptoService);
|
|
ctx.getBeanFactory().registerSingleton("cryptoModuleManager", manager);
|
|
ctx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper());
|
|
ctx.registerBeanDefinition("applicationContextProvider",
|
|
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
|
|
.getBeanDefinition());
|
|
ctx.refresh();
|
|
|
|
manager.start();
|
|
}
|
|
|
|
@AfterAll
|
|
static void tearDownClass() {
|
|
if (ctx != null) ctx.close();
|
|
}
|
|
|
|
@BeforeEach
|
|
void resetMocks() {
|
|
reset(mockLoader);
|
|
when(mockLoader.findAll()).thenReturn(Collections.emptyList());
|
|
}
|
|
|
|
// =========================================================================
|
|
// 1. start / stop Lifecycle
|
|
// =========================================================================
|
|
|
|
@Test
|
|
@DisplayName("1-1. start() 후 isStarted() = true")
|
|
void testIsStarted() {
|
|
assertTrue(manager.isStarted());
|
|
}
|
|
|
|
// =========================================================================
|
|
// 2. STATIC 키 — createExtension
|
|
// =========================================================================
|
|
|
|
@Test
|
|
@DisplayName("2-1. STATIC AES/CBC — createExtension 반환 및 암복호화 동작")
|
|
void testCreateExtension_staticAesCbc_roundTrip() throws Exception {
|
|
CryptoModuleConfig config = buildStaticConfig("AES_CBC", "AES", "CBC", "PKCS5Padding");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
byte[] plain = "테스트 평문".getBytes(StandardCharsets.UTF_8);
|
|
CryptoModuleExtension ext = manager.createExtension("AES_CBC");
|
|
byte[] encrypted = ext.encrypt(plain, 0, plain.length);
|
|
|
|
CryptoModuleExtension extDec = manager.createExtension("AES_CBC");
|
|
byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length);
|
|
|
|
assertArrayEquals(plain, decrypted, "STATIC AES/CBC 암복호화 라운드트립 성공해야 한다");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("2-2. STATIC ARIA/CBC — createExtension 반환 및 암복호화 동작")
|
|
void testCreateExtension_staticAriaCbc_roundTrip() throws Exception {
|
|
CryptoModuleConfig config = buildStaticConfig("ARIA_CBC", "ARIA", "CBC", "PKCS5Padding");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
byte[] plain = "ARIA 테스트".getBytes(StandardCharsets.UTF_8);
|
|
CryptoModuleExtension ext = manager.createExtension("ARIA_CBC");
|
|
byte[] encrypted = ext.encrypt(plain, 0, plain.length);
|
|
|
|
CryptoModuleExtension extDec = manager.createExtension("ARIA_CBC");
|
|
byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length);
|
|
|
|
assertArrayEquals(plain, decrypted);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("2-3. 미등록 cryptoName — IllegalArgumentException")
|
|
void testCreateExtension_unknownName_throwsException() {
|
|
assertThrows(IllegalArgumentException.class,
|
|
() -> manager.createExtension("UNKNOWN_MODULE"),
|
|
"미등록 cryptoName은 IllegalArgumentException이어야 한다");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("2-4. use_yn=N 설정 — configMap 미등록")
|
|
void testLoadAll_disabledConfig_notRegistered() throws Exception {
|
|
CryptoModuleConfig config = buildStaticConfig("DISABLED_MOD", "AES", "CBC", "PKCS5Padding");
|
|
config.setUseYn("N");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
assertThrows(IllegalArgumentException.class,
|
|
() -> manager.createExtension("DISABLED_MOD"),
|
|
"use_yn=N 설정은 로드되지 않아야 한다");
|
|
}
|
|
|
|
// =========================================================================
|
|
// 3. DYNAMIC 키 — createExtension with runtimeContext
|
|
// =========================================================================
|
|
|
|
@Test
|
|
@DisplayName("3-1. DYNAMIC AES/CBC — runtimeContext 전달 시 암복호화 동작")
|
|
void testCreateExtension_dynamicAesCbc_roundTrip() throws Exception {
|
|
CryptoModuleConfig config = buildDynamicConfig("DYN_AES_CBC", "AES", "CBC", "PKCS5Padding");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
Map<String, String> ctx = new HashMap<>();
|
|
ctx.put("X-Api-Enc-Key", "clientKeyValue01");
|
|
|
|
byte[] plain = "DYNAMIC 키 테스트".getBytes(StandardCharsets.UTF_8);
|
|
CryptoModuleExtension ext = manager.createExtension("DYN_AES_CBC", ctx);
|
|
byte[] encrypted = ext.encrypt(plain, 0, plain.length);
|
|
|
|
CryptoModuleExtension extDec = manager.createExtension("DYN_AES_CBC", ctx);
|
|
byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length);
|
|
|
|
assertArrayEquals(plain, decrypted, "DYNAMIC AES/CBC 암복호화 라운드트립 성공해야 한다");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("3-2. DYNAMIC — 동일 runtimeContext로 2회 호출 시 HSM은 1회만 호출 (캐시 적용)")
|
|
void testCreateExtension_dynamicCached_hsmCalledOnce() throws Exception {
|
|
CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE", "AES", "CBC", "PKCS5Padding");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
Map<String, String> runtimeCtx = new HashMap<>();
|
|
runtimeCtx.put("X-Api-Enc-Key", "cachedKeyValue01");
|
|
|
|
manager.createExtension("DYN_CACHE", runtimeCtx);
|
|
manager.createExtension("DYN_CACHE", runtimeCtx);
|
|
|
|
verify(mockHsmCryptoService, times(1)).getSecretKey(anyString());
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("3-3. DYNAMIC — 잘못된 전략 FQCN — IllegalArgumentException")
|
|
void testCreateExtension_invalidStrategyFqcn_throwsException() throws Exception {
|
|
CryptoModuleConfig config = buildDynamicConfig("DYN_BAD_FQCN", "AES", "CBC", "PKCS5Padding");
|
|
config.setKeyDerivStrategy("com.example.NonExistentStrategy");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
Map<String, String> runtimeCtx = new HashMap<>();
|
|
runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01");
|
|
|
|
assertThrows(IllegalArgumentException.class,
|
|
() -> manager.createExtension("DYN_BAD_FQCN", runtimeCtx),
|
|
"존재하지 않는 FQCN이면 IllegalArgumentException이 발생해야 한다");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("3-4. DYNAMIC — 동일 FQCN 반복 호출 시 strategyCache에서 재사용")
|
|
void testCreateExtension_sameFqcn_strategyCacheReused() throws Exception {
|
|
CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE_STRAT", "AES", "CBC", "PKCS5Padding");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
Map<String, String> runtimeCtx = new HashMap<>();
|
|
runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01");
|
|
|
|
manager.createExtension("DYN_CACHE_STRAT", runtimeCtx);
|
|
manager.createExtension("DYN_CACHE_STRAT", runtimeCtx);
|
|
|
|
Field stratCacheField = CryptoModuleManager.class.getDeclaredField("strategyCache");
|
|
stratCacheField.setAccessible(true);
|
|
Map<?, ?> stratCache = (Map<?, ?>) stratCacheField.get(manager);
|
|
|
|
assertEquals(1, stratCache.size(), "동일 FQCN은 strategyCache에 하나만 존재해야 한다");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("3-5. DYNAMIC — 다른 runtimeContext 값은 다른 키 도출 (동적 키 캐시 미사용)")
|
|
void testCreateExtension_dynamicDifferentContext_differentKey() throws Exception {
|
|
CryptoModuleConfig config = buildDynamicConfig("DYN_DIFF", "AES", "CBC", "PKCS5Padding");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
Map<String, String> ctx1 = new HashMap<>();
|
|
ctx1.put("X-Api-Enc-Key", "keyValueAAA00001");
|
|
|
|
Map<String, String> ctx2 = new HashMap<>();
|
|
ctx2.put("X-Api-Enc-Key", "keyValueBBB00001");
|
|
|
|
manager.createExtension("DYN_DIFF", ctx1);
|
|
manager.createExtension("DYN_DIFF", ctx2);
|
|
|
|
// 컨텍스트 값이 달라 동적 키 캐시 미사용 → HSM 2회 호출
|
|
verify(mockHsmCryptoService, times(2)).getSecretKey(anyString());
|
|
}
|
|
|
|
// =========================================================================
|
|
// 4. reload
|
|
// =========================================================================
|
|
|
|
@Test
|
|
@DisplayName("4-1. reload(cryptoId) — 변경된 설정 반영")
|
|
void testReload_specificId_configUpdated() throws Exception {
|
|
CryptoModuleConfig config = buildStaticConfig("RELOAD_MOD", "AES", "CBC", "PKCS5Padding");
|
|
config.setCryptoId("reload-id-001");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
CryptoModuleConfig updated = buildStaticConfig("RELOAD_MOD", "ARIA", "CBC", "PKCS5Padding");
|
|
updated.setCryptoId("reload-id-001");
|
|
when(mockLoader.findById("reload-id-001")).thenReturn(Optional.of(updated));
|
|
|
|
manager.reload("reload-id-001");
|
|
|
|
// 변경된 설정(ARIA)으로 암복호화 가능한지 확인
|
|
byte[] plain = "리로드 테스트".getBytes(StandardCharsets.UTF_8);
|
|
CryptoModuleExtension ext = manager.createExtension("RELOAD_MOD");
|
|
assertNotNull(ext);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("4-2. reload(cryptoId) use_yn=N — configMap에서 제거")
|
|
void testReload_specificId_disabled_removedFromMap() throws Exception {
|
|
CryptoModuleConfig config = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding");
|
|
config.setCryptoId("rm-id-001");
|
|
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
|
|
reload();
|
|
|
|
CryptoModuleConfig disabled = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding");
|
|
disabled.setCryptoId("rm-id-001");
|
|
disabled.setUseYn("N");
|
|
when(mockLoader.findById("rm-id-001")).thenReturn(Optional.of(disabled));
|
|
|
|
manager.reload("rm-id-001");
|
|
|
|
assertThrows(IllegalArgumentException.class,
|
|
() -> manager.createExtension("RM_MOD"),
|
|
"비활성화된 모듈은 configMap에서 제거되어야 한다");
|
|
}
|
|
|
|
// =========================================================================
|
|
// 헬퍼
|
|
// =========================================================================
|
|
|
|
private void reload() throws Exception {
|
|
clearField("configMap");
|
|
clearField("dynamicKeyCache");
|
|
clearField("strategyCache");
|
|
|
|
Method loadAll = CryptoModuleManager.class.getDeclaredMethod("loadAll");
|
|
loadAll.setAccessible(true);
|
|
loadAll.invoke(manager);
|
|
|
|
reset(mockHsmCryptoService);
|
|
SecretKey masterKey = new SecretKeySpec(KEY_128, "AES");
|
|
when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey);
|
|
}
|
|
|
|
private void clearField(String fieldName) throws Exception {
|
|
Field field = CryptoModuleManager.class.getDeclaredField(fieldName);
|
|
field.setAccessible(true);
|
|
((Map<?, ?>) field.get(manager)).clear();
|
|
}
|
|
|
|
private CryptoModuleConfig buildStaticConfig(String name, String alg, String mode, String padding) {
|
|
CryptoModuleConfig c = new CryptoModuleConfig();
|
|
c.setCryptoId(java.util.UUID.randomUUID().toString());
|
|
c.setCryptoName(name);
|
|
c.setAlgType(alg);
|
|
c.setCipherMode(mode);
|
|
c.setPadding(padding);
|
|
c.setIvHex(IV_HEX);
|
|
c.setKeySourceType("STATIC");
|
|
c.setEncKeyHex(ENC_KEY_HEX);
|
|
c.setDecKeyHex(ENC_KEY_HEX);
|
|
c.setCacheYn("Y");
|
|
c.setCacheTtlSec(300);
|
|
c.setUseYn("Y");
|
|
return c;
|
|
}
|
|
|
|
private CryptoModuleConfig buildDynamicConfig(String name, String alg, String mode, String padding) {
|
|
CryptoModuleConfig c = new CryptoModuleConfig();
|
|
c.setCryptoId(java.util.UUID.randomUUID().toString());
|
|
c.setCryptoName(name);
|
|
c.setAlgType(alg);
|
|
c.setCipherMode(mode);
|
|
c.setPadding(padding);
|
|
c.setIvHex(IV_HEX);
|
|
c.setKeySourceType("DYNAMIC");
|
|
c.setKeyDerivStrategy("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy");
|
|
c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\",\"contextKey\":\"X-Api-Enc-Key\",\"offset\":\"0\",\"length\":\"16\"}");
|
|
c.setCacheYn("Y");
|
|
c.setCacheTtlSec(300);
|
|
c.setUseYn("Y");
|
|
return c;
|
|
}
|
|
|
|
private static String toHex(byte[] bytes) {
|
|
StringBuilder sb = new StringBuilder();
|
|
for (byte b : bytes) sb.append(String.format("%02x", b));
|
|
return sb.toString();
|
|
}
|
|
|
|
/** MapStruct APT 없이도 동작하는 인라인 매퍼 구현체 */
|
|
private static CryptoModuleConfigMapper testMapper() {
|
|
return new CryptoModuleConfigMapper() {
|
|
@Override
|
|
public CryptoModuleConfigVO toVo(CryptoModuleConfig e) {
|
|
CryptoModuleConfigVO vo = new CryptoModuleConfigVO();
|
|
vo.setCryptoId(e.getCryptoId());
|
|
vo.setCryptoName(e.getCryptoName());
|
|
vo.setCryptoDesc(e.getCryptoDesc());
|
|
vo.setAlgType(e.getAlgType());
|
|
vo.setCipherMode(e.getCipherMode());
|
|
vo.setPadding(e.getPadding());
|
|
vo.setIvHex(e.getIvHex());
|
|
vo.setKeySourceType(e.getKeySourceType());
|
|
vo.setEncKeyHex(e.getEncKeyHex());
|
|
vo.setDecKeyHex(e.getDecKeyHex());
|
|
vo.setKeyDerivStrategy(e.getKeyDerivStrategy());
|
|
vo.setKeyDerivParams(e.getKeyDerivParams());
|
|
vo.setCacheYn(e.getCacheYn());
|
|
vo.setCacheTtlSec(e.getCacheTtlSec());
|
|
vo.setUseYn(e.getUseYn());
|
|
return vo;
|
|
}
|
|
@Override
|
|
public CryptoModuleConfig toEntity(CryptoModuleConfigVO vo) {
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
}
|