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.Base64; 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"); System.out.println("masterKey:"+new String(KEY_128)); 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"); CryptoModuleConfig gcm = buildStatic("AES_GCM_SVC", "AES", "GCM", "NoPadding"); CryptoModuleConfig gcmDyn = buildDynamic("DYN_GCM_SVC", "AES", "GCM", "NoPadding"); when(mockLoader.findAll()).thenReturn(Arrays.asList(aes, aria, ecb, dyn, gcm, gcmDyn)); Constructor 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 runtimeCtx = new HashMap<>(); runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001"); byte[] plain = "{\"test\": \"DYNAMIC AES/CBC 테스트\"}".getBytes(StandardCharsets.UTF_8); byte[] encrypted = service.encrypt("DYN_SVC", runtimeCtx, plain); byte[] decrypted = service.decrypt("DYN_SVC", runtimeCtx, encrypted); String encBase64 = Base64.getEncoder().encodeToString(encrypted); String decBase64 = Base64.getEncoder().encodeToString(decrypted); System.out.println("DYNAMIC AES/CBC"); System.out.println("plain:"+new String(plain)); System.out.println("encBase64:"+encBase64); System.out.println("decBase64:"+decBase64); System.out.println("plain:"+new String(decrypted)); assertArrayEquals(plain, decrypted); } @Test @DisplayName("2-2. DYNAMIC — 다른 runtimeContext 값으로 복호화 시 실패") void testDynamic_differentContext_decryptFails() throws Exception { Map encCtx = new HashMap<>(); encCtx.put("X-Api-Enc-Key", "encryptKeyValue0"); Map 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 모드에서 블록 크기 미정렬 평문은 예외가 발생해야 한다"); } // ========================================================================= // 4. STATIC AAD — encrypt(name, aad, plain) / decrypt(name, aad, cipher) // ========================================================================= @Test @DisplayName("4-1. STATIC AES/GCM — AAD 명시 encrypt → decrypt 라운드트립") void testStaticGcm_explicitAad_roundTrip() throws Exception { byte[] aad = "request-header-ctx".getBytes(StandardCharsets.UTF_8); byte[] plain = "GCM AAD 서비스 테스트".getBytes(StandardCharsets.UTF_8); byte[] encrypted = service.encrypt("AES_GCM_SVC", aad, plain); byte[] decrypted = service.decrypt("AES_GCM_SVC", aad, encrypted); assertArrayEquals(plain, decrypted); } @Test @DisplayName("4-2. STATIC AES/GCM — AAD null이면 IV를 AAD로 사용하는 기본 동작") void testStaticGcm_nullAad_usesIvAsAad() throws Exception { byte[] plain = "{\"test\": \"테스트\"}".getBytes(StandardCharsets.UTF_8); byte[] encrypted = service.encrypt("AES_GCM_SVC", (byte[]) null, plain); byte[] decrypted = service.decrypt("AES_GCM_SVC", (byte[]) null, encrypted); assertArrayEquals(plain, decrypted); String encBase64 = Base64.getEncoder().encodeToString(encrypted); String decBase64 = Base64.getEncoder().encodeToString(decrypted); System.out.println("STATIC AES/GCM"); System.out.println("plain:"+new String(plain)); System.out.println("encBase64:"+encBase64); System.out.println("decBase64:"+decBase64); System.out.println("plain:"+new String(decrypted)); } @Test @DisplayName("4-3. STATIC AES/GCM — 다른 AAD로 복호화 시 인증 실패 예외") void testStaticGcm_wrongAad_throwsAuthException() throws Exception { byte[] aad = "correct-aad-value".getBytes(StandardCharsets.UTF_8); byte[] wrongAad = "wrong-aad-value!!".getBytes(StandardCharsets.UTF_8); byte[] plain = "GCM 인증 실패 테스트".getBytes(StandardCharsets.UTF_8); byte[] encrypted = service.encrypt("AES_GCM_SVC", aad, plain); assertThrows(Exception.class, () -> service.decrypt("AES_GCM_SVC", wrongAad, encrypted), "잘못된 AAD로 GCM 복호화 시 인증 실패 예외가 발생해야 한다"); } @Test @DisplayName("4-4. STATIC CBC — AAD 파라미터는 무시되고 정상 복호화") void testStaticCbc_aadIgnored_normalDecrypt() throws Exception { byte[] aad = "ignored-aad".getBytes(StandardCharsets.UTF_8); byte[] plain = "CBC AAD 무시 테스트".getBytes(StandardCharsets.UTF_8); byte[] encrypted = service.encrypt("AES_SVC", aad, plain); byte[] decrypted = service.decrypt("AES_SVC", aad, encrypted); assertArrayEquals(plain, decrypted, "CBC 모드에서 AAD는 무시되고 정상 암복호화되어야 한다"); } // ========================================================================= // 5. DYNAMIC AAD — encrypt(name, ctx, aad, plain) / decrypt(name, ctx, aad, cipher) // ========================================================================= @Test @DisplayName("5-1. DYNAMIC AES/GCM — runtimeContext + AAD 명시 encrypt → decrypt 라운드트립") void testDynamicGcm_explicitAad_roundTrip() throws Exception { Map runtimeCtx = new HashMap<>(); runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001"); byte[] aad = "dynamic-gcm-aad-ctx".getBytes(StandardCharsets.UTF_8); byte[] plain = "{\"test\": \"테스트\"}".getBytes(StandardCharsets.UTF_8); byte[] encrypted = service.encrypt("DYN_GCM_SVC", runtimeCtx, aad, plain); byte[] decrypted = service.decrypt("DYN_GCM_SVC", runtimeCtx, aad, encrypted); String encBase64 = Base64.getEncoder().encodeToString(encrypted); String decBase64 = Base64.getEncoder().encodeToString(decrypted); System.out.println("DYNAMIC AES/GCM"); System.out.println("aad:"+new String(aad)); System.out.println("plain:"+new String(plain)); System.out.println("encBase64:"+encBase64); System.out.println("decBase64:"+decBase64); System.out.println("plain:"+new String(decrypted)); assertArrayEquals(plain, decrypted); } @Test @DisplayName("5-2. DYNAMIC AES/GCM — AAD null이면 IV를 AAD로 사용하는 기본 동작") void testDynamicGcm_nullAad_usesIvAsAad() throws Exception { Map runtimeCtx = new HashMap<>(); runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001"); byte[] plain = "{\"test\": \"테스트\"}".getBytes(StandardCharsets.UTF_8); byte[] encrypted = service.encrypt("DYN_GCM_SVC", runtimeCtx, null, plain); byte[] decrypted = service.decrypt("DYN_GCM_SVC", runtimeCtx, null, encrypted); String encBase64 = Base64.getEncoder().encodeToString(encrypted); String decBase64 = Base64.getEncoder().encodeToString(decrypted); System.out.println("DYNAMIC AES/GCM"); System.out.println("aad:null"); System.out.println("plain:"+new String(plain)); System.out.println("encBase64:"+encBase64); System.out.println("decBase64:"+decBase64); System.out.println("plain:"+new String(decrypted)); assertArrayEquals(plain, decrypted); } @Test @DisplayName("5-3. DYNAMIC AES/GCM — 다른 AAD로 복호화 시 인증 실패 예외") void testDynamicGcm_wrongAad_throwsAuthException() throws Exception { Map runtimeCtx = new HashMap<>(); runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001"); byte[] aad = "correct-aad-value".getBytes(StandardCharsets.UTF_8); byte[] wrongAad = "wrong-aad-value!!".getBytes(StandardCharsets.UTF_8); byte[] plain = "DYNAMIC GCM 인증 실패".getBytes(StandardCharsets.UTF_8); byte[] encrypted = service.encrypt("DYN_GCM_SVC", runtimeCtx, aad, plain); assertThrows(Exception.class, () -> service.decrypt("DYN_GCM_SVC", runtimeCtx, wrongAad, encrypted), "잘못된 AAD로 DYNAMIC GCM 복호화 시 인증 실패 예외가 발생해야 한다"); } @Test @DisplayName("5-4. DYNAMIC CBC — AAD 파라미터는 무시되고 정상 복호화") void testDynamicCbc_aadIgnored_normalDecrypt() throws Exception { Map runtimeCtx = new HashMap<>(); runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001"); byte[] aad = "ignored-aad".getBytes(StandardCharsets.UTF_8); byte[] plain = "DYNAMIC CBC AAD 무시 테스트".getBytes(StandardCharsets.UTF_8); byte[] encrypted = service.encrypt("DYN_SVC", runtimeCtx, aad, plain); byte[] decrypted = service.decrypt("DYN_SVC", runtimeCtx, aad, encrypted); assertArrayEquals(plain, decrypted, "DYNAMIC CBC 모드에서 AAD는 무시되고 정상 암복호화되어야 한다"); } // ========================================================================= // 헬퍼 // ========================================================================= 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.HsmKeyDerivationStrategy"); c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\"}"); 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; } }; } }