feat: 암호화모듈 프레임워크 신규 구현
- 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>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
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.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* CryptoModuleService 단위 테스트
|
||||
* CryptoModuleManager를 통한 암복호화 진입점 전체 흐름을 검증한다.
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.DisplayName.class)
|
||||
class CryptoModuleServiceTest {
|
||||
|
||||
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 CryptoModuleManager manager;
|
||||
private static CryptoModuleService service;
|
||||
private static CryptoModuleConfigLoader mockLoader;
|
||||
private static HsmCryptoService mockHsm;
|
||||
|
||||
@BeforeAll
|
||||
static void setUpClass() throws Exception {
|
||||
mockLoader = mock(CryptoModuleConfigLoader.class);
|
||||
mockHsm = mock(HsmCryptoService.class);
|
||||
|
||||
SecretKey masterKey = new SecretKeySpec(KEY_128, "AES");
|
||||
when(mockHsm.getSecretKey(anyString())).thenReturn(masterKey);
|
||||
|
||||
CryptoModuleConfig aes = buildStatic("AES_SVC", "AES", "CBC", "PKCS5Padding");
|
||||
CryptoModuleConfig aria = buildStatic("ARIA_SVC", "ARIA", "CBC", "PKCS5Padding");
|
||||
CryptoModuleConfig ecb = buildStaticNoIv("AES_ECB_SVC", "AES", "ECB", "NoPadding");
|
||||
CryptoModuleConfig dyn = buildDynamic("DYN_SVC", "AES", "CBC", "PKCS5Padding");
|
||||
when(mockLoader.findAll()).thenReturn(Arrays.asList(aes, aria, ecb, dyn));
|
||||
|
||||
Constructor<CryptoModuleManager> ctor = CryptoModuleManager.class.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
manager = ctor.newInstance();
|
||||
service = new CryptoModuleService();
|
||||
|
||||
ctx = new GenericApplicationContext();
|
||||
ctx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader);
|
||||
ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsm);
|
||||
ctx.getBeanFactory().registerSingleton("cryptoModuleManager", manager);
|
||||
ctx.getBeanFactory().registerSingleton("cryptoModuleService", service);
|
||||
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();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 1. STATIC 키 — encrypt / decrypt
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("1-1. STATIC AES/CBC — encrypt → decrypt 라운드트립")
|
||||
void testStaticAes_encryptDecrypt() throws Exception {
|
||||
byte[] plain = "CryptoModuleService AES 테스트".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
byte[] encrypted = service.encrypt("AES_SVC", plain);
|
||||
byte[] decrypted = service.decrypt("AES_SVC", encrypted);
|
||||
|
||||
assertArrayEquals(plain, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1-2. STATIC ARIA/CBC — encrypt → decrypt 라운드트립")
|
||||
void testStaticAria_encryptDecrypt() throws Exception {
|
||||
byte[] plain = "CryptoModuleService ARIA 테스트".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
byte[] encrypted = service.encrypt("ARIA_SVC", plain);
|
||||
byte[] decrypted = service.decrypt("ARIA_SVC", encrypted);
|
||||
|
||||
assertArrayEquals(plain, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1-3. 암호문은 평문과 다른 바이트 배열")
|
||||
void testEncrypt_ciphertextDiffersFromPlaintext() throws Exception {
|
||||
byte[] plain = "평문입니다.".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
byte[] encrypted = service.encrypt("AES_SVC", plain);
|
||||
|
||||
assertFalse(java.util.Arrays.equals(plain, encrypted));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1-4. 미등록 모듈명 — IllegalArgumentException")
|
||||
void testEncrypt_unknownModule_throwsException() {
|
||||
byte[] plain = "test".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.encrypt("UNKNOWN_SVC", plain));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. DYNAMIC 키 — encrypt / decrypt
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("2-1. DYNAMIC AES/CBC — runtimeContext 전달 encrypt → decrypt 라운드트립")
|
||||
void testDynamicAes_encryptDecrypt() throws Exception {
|
||||
Map<String, String> runtimeCtx = new HashMap<>();
|
||||
runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001");
|
||||
|
||||
byte[] plain = "DYNAMIC 키 서비스 테스트".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
byte[] encrypted = service.encrypt("DYN_SVC", runtimeCtx, plain);
|
||||
byte[] decrypted = service.decrypt("DYN_SVC", runtimeCtx, encrypted);
|
||||
|
||||
assertArrayEquals(plain, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2-2. DYNAMIC — 다른 runtimeContext 값으로 복호화 시 실패")
|
||||
void testDynamic_differentContext_decryptFails() throws Exception {
|
||||
Map<String, String> encCtx = new HashMap<>();
|
||||
encCtx.put("X-Api-Enc-Key", "encryptKeyValue0");
|
||||
|
||||
Map<String, String> decCtx = new HashMap<>();
|
||||
decCtx.put("X-Api-Enc-Key", "differentKeyVal0");
|
||||
|
||||
byte[] plain = "키불일치 테스트".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] encrypted = service.encrypt("DYN_SVC", encCtx, plain);
|
||||
|
||||
assertThrows(Exception.class,
|
||||
() -> service.decrypt("DYN_SVC", decCtx, encrypted),
|
||||
"다른 컨텍스트 키로 복호화 시 예외가 발생해야 한다");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. AES/ECB/NoPadding
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("3-1. AES/ECB/NoPadding — 16바이트 평문 encrypt → decrypt 라운드트립")
|
||||
void testEcb_encryptDecrypt_exactBlock() throws Exception {
|
||||
byte[] plain = "ExactSixteenByte".getBytes(StandardCharsets.UTF_8); // 정확히 16바이트
|
||||
|
||||
byte[] encrypted = service.encrypt("AES_ECB_SVC", plain);
|
||||
byte[] decrypted = service.decrypt("AES_ECB_SVC", encrypted);
|
||||
|
||||
assertArrayEquals(plain, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3-2. AES/ECB/NoPadding — 동일 평문은 항상 동일 암호문 (ECB 특성)")
|
||||
void testEcb_deterministicOutput() throws Exception {
|
||||
byte[] plain = "ExactSixteenByte".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
byte[] enc1 = service.encrypt("AES_ECB_SVC", plain);
|
||||
byte[] enc2 = service.encrypt("AES_ECB_SVC", plain);
|
||||
|
||||
assertArrayEquals(enc1, enc2, "ECB는 IV 없으므로 동일 평문 → 동일 암호문");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3-3. AES/ECB/NoPadding — 16 배수 아닌 평문 → 예외")
|
||||
void testEcb_nonBlockAligned_throwsException() {
|
||||
byte[] plain = "NotSixteenBytes!X".getBytes(StandardCharsets.UTF_8); // 17바이트
|
||||
|
||||
assertThrows(Exception.class,
|
||||
() -> service.encrypt("AES_ECB_SVC", plain),
|
||||
"NoPadding 모드에서 블록 크기 미정렬 평문은 예외가 발생해야 한다");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼
|
||||
// =========================================================================
|
||||
|
||||
private static CryptoModuleConfig buildStatic(String name, String alg, String mode, String padding) {
|
||||
CryptoModuleConfig c = new CryptoModuleConfig();
|
||||
c.setCryptoId(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 static CryptoModuleConfig buildStaticNoIv(String name, String alg, String mode, String padding) {
|
||||
CryptoModuleConfig c = new CryptoModuleConfig();
|
||||
c.setCryptoId(UUID.randomUUID().toString());
|
||||
c.setCryptoName(name);
|
||||
c.setAlgType(alg);
|
||||
c.setCipherMode(mode);
|
||||
c.setPadding(padding);
|
||||
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 static CryptoModuleConfig buildDynamic(String name, String alg, String mode, String padding) {
|
||||
CryptoModuleConfig c = new CryptoModuleConfig();
|
||||
c.setCryptoId(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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user