From 444b7ad595c2c8bc6f2cdf57e33c8d76d9d68edd Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 12 May 2026 18:15:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20CryptoFilter=20GCM=20AAD=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CryptoFilter: crypto.aad.header 프로퍼티 + buildAad() 메서드 추가 - CryptoFilter: 기본 scope SCOPE_BODY → SCOPE_FIELD 변경 - CryptoFilter: decryptBody/decryptField/encryptBody/encryptField aad 파라미터 적용 - BaseCryptoFilterTest: BODY/FIELD scope, buildAad, AAD 헤더 연동 단위테스트 신규 작성 - CryptoModuleServiceTest: STATIC/DYNAMIC GCM AAD 테스트 섹션 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../http/dynamic/filter/CryptoFilter.java | 52 ++- .../dynamic/filter/BaseCryptoFilterTest.java | 344 ++++++++++++++++++ .../security/CryptoModuleServiceTest.java | 130 ++++++- 3 files changed, 505 insertions(+), 21 deletions(-) create mode 100644 src/test/java/com/eactive/eai/adapter/http/dynamic/filter/BaseCryptoFilterTest.java diff --git a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/CryptoFilter.java b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/CryptoFilter.java index 7261abd..a4b1fc6 100644 --- a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/CryptoFilter.java +++ b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/CryptoFilter.java @@ -35,19 +35,24 @@ import com.eactive.eai.util.JsonPathUtil; * crypto.dec.to.path 복호화 결과 반영 경로. "/" = 전체 body 교체. * crypto.enc.from.path 암호화 대상 JSON 경로. "/" = 전체 body 암호화. * crypto.enc.to.path 암호화 결과(Base64)를 넣을 JSON 경로 (예: /encryptedData) + * + * GCM AAD 전용 (선택): + * crypto.aad.header AAD로 사용할 HTTP 요청 헤더명 (예: X-Api-Request-Id). + * 미설정 또는 헤더값 없으면 aad=null → IV를 AAD 대체값으로 사용. */ public abstract class CryptoFilter implements HttpAdapterFilter { protected static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER); - public static final String PROP_MODULE_NAME = "crypto.module.name"; - public static final String PROP_SCOPE = "crypto.scope"; - public static final String PROP_DEC_ENABLED = "crypto.dec.enabled"; - public static final String PROP_ENC_ENABLED = "crypto.enc.enabled"; + public static final String PROP_MODULE_NAME = "crypto.module.name"; + public static final String PROP_SCOPE = "crypto.scope"; + public static final String PROP_DEC_ENABLED = "crypto.dec.enabled"; + public static final String PROP_ENC_ENABLED = "crypto.enc.enabled"; public static final String PROP_DEC_FROM_PATH = "crypto.dec.from.path"; public static final String PROP_DEC_TO_PATH = "crypto.dec.to.path"; public static final String PROP_ENC_FROM_PATH = "crypto.enc.from.path"; public static final String PROP_ENC_TO_PATH = "crypto.enc.to.path"; + public static final String PROP_AAD_HEADER = "crypto.aad.header"; protected static final String SCOPE_BODY = "BODY"; protected static final String SCOPE_FIELD = "FIELD"; @@ -79,13 +84,14 @@ public abstract class CryptoFilter implements HttpAdapterFilter { String scope = prop.getProperty(PROP_SCOPE, SCOPE_FIELD).toUpperCase(); String body = toBodyString(message); Map runtimeCtx = buildRuntimeContext(prop, request); + byte[] aad = buildAad(prop, request); if (SCOPE_FIELD.equals(scope)) { - return decryptField(body, moduleName, runtimeCtx, + return decryptField(body, moduleName, runtimeCtx, aad, required(prop, PROP_DEC_FROM_PATH), prop.getProperty(PROP_DEC_TO_PATH, PATH_ROOT)); } - return decryptBody(body, moduleName, runtimeCtx); + return decryptBody(body, moduleName, runtimeCtx, aad); } // ------------------------------------------------------------------ @@ -104,22 +110,23 @@ public abstract class CryptoFilter implements HttpAdapterFilter { String scope = prop.getProperty(PROP_SCOPE, SCOPE_FIELD).toUpperCase(); String body = toBodyString(resultMessage); Map runtimeCtx = buildRuntimeContext(prop, request); + byte[] aad = buildAad(prop, request); if (SCOPE_FIELD.equals(scope)) { - return encryptField(body, moduleName, runtimeCtx, + return encryptField(body, moduleName, runtimeCtx, aad, prop.getProperty(PROP_ENC_FROM_PATH, PATH_ROOT), required(prop, PROP_ENC_TO_PATH)); } - return encryptBody(body, moduleName, runtimeCtx); + return encryptBody(body, moduleName, runtimeCtx, aad); } // ------------------------------------------------------------------ // 복호화 구현 // ------------------------------------------------------------------ - private String decryptBody(String body, String moduleName, Map runtimeCtx) throws Exception { + private String decryptBody(String body, String moduleName, Map runtimeCtx, byte[] aad) throws Exception { byte[] cipherBytes = Base64.getDecoder().decode(body.trim()); - byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, cipherBytes); + byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, aad, cipherBytes); return new String(plainBytes, StandardCharsets.UTF_8); } @@ -127,7 +134,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter { * fromPath의 Base64 값을 복호화하여 toPath에 반영. * toPath = "/" → 복호화된 텍스트로 body 전체 교체. */ - private String decryptField(String body, String moduleName, Map runtimeCtx, + private String decryptField(String body, String moduleName, Map runtimeCtx, byte[] aad, String fromPath, String toPath) throws Exception { String encBase64 = JsonPathUtil.getValueAtPath(body, fromPath); @@ -137,7 +144,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter { } byte[] cipherBytes = Base64.getDecoder().decode(encBase64.trim()); - byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, cipherBytes); + byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, aad, cipherBytes); String plainText = new String(plainBytes, StandardCharsets.UTF_8); if (PATH_ROOT.equals(toPath)) { @@ -150,8 +157,8 @@ public abstract class CryptoFilter implements HttpAdapterFilter { // 암호화 구현 // ------------------------------------------------------------------ - private String encryptBody(String body, String moduleName, Map runtimeCtx) throws Exception { - byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, + private String encryptBody(String body, String moduleName, Map runtimeCtx, byte[] aad) throws Exception { + byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, aad, body.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(cipherBytes); } @@ -160,7 +167,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter { * fromPath 값을 암호화하여 toPath(Base64)에 반영. * fromPath = "/" → body 전체를 암호화하여 toPath 필드명으로 래핑. */ - private String encryptField(String body, String moduleName, Map runtimeCtx, + private String encryptField(String body, String moduleName, Map runtimeCtx, byte[] aad, String fromPath, String toPath) throws Exception { String plainText; @@ -174,7 +181,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter { } } - byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, + byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, aad, plainText.getBytes(StandardCharsets.UTF_8)); String encBase64 = Base64.getEncoder().encodeToString(cipherBytes); @@ -208,6 +215,19 @@ public abstract class CryptoFilter implements HttpAdapterFilter { return message.toString(); } + /** + * crypto.aad.header 프로퍼티에 지정된 헤더값을 UTF-8 바이트로 반환한다. + * 프로퍼티 미설정 또는 헤더값 없으면 null을 반환하여 GCM 기본 동작(IV를 AAD 대체값으로 사용)을 따른다. + */ + protected byte[] buildAad(Properties prop, HttpServletRequest request) { + String headerName = prop.getProperty(PROP_AAD_HEADER); + if (StringUtils.isBlank(headerName) || request == null) { + return null; + } + String headerValue = request.getHeader(headerName); + return StringUtils.isBlank(headerValue) ? null : headerValue.getBytes(StandardCharsets.UTF_8); + } + private boolean isEnabled(Properties prop, String key, String defaultVal) { return !"N".equalsIgnoreCase(prop.getProperty(key, defaultVal)); } diff --git a/src/test/java/com/eactive/eai/adapter/http/dynamic/filter/BaseCryptoFilterTest.java b/src/test/java/com/eactive/eai/adapter/http/dynamic/filter/BaseCryptoFilterTest.java new file mode 100644 index 0000000..243ca28 --- /dev/null +++ b/src/test/java/com/eactive/eai/adapter/http/dynamic/filter/BaseCryptoFilterTest.java @@ -0,0 +1,344 @@ +package com.eactive.eai.adapter.http.dynamic.filter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.Properties; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +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.security.CryptoModuleService; +import com.eactive.eai.common.util.ApplicationContextProvider; + +/** + * BaseCryptoFilter / CryptoFilter 단위 테스트. + * CryptoModuleService는 Mock으로 대체하여 필터 로직(Base64, JSON 경로, 프로퍼티 파싱)만 검증한다. + */ +@TestMethodOrder(MethodOrderer.DisplayName.class) +class BaseCryptoFilterTest { + + private static final String MODULE = "TEST_MODULE"; + private static final String PLAIN_STR = "decrypted-plain-text"; + private static final byte[] PLAIN = PLAIN_STR.getBytes(StandardCharsets.UTF_8); + private static final byte[] CIPHER = new byte[]{0x10, 0x20, 0x30, 0x40, 0x50, 0x60}; + private static final String CIPHER_B64 = Base64.getEncoder().encodeToString(CIPHER); + + private static GenericApplicationContext ctx; + private static CryptoModuleService mockService; + private static BaseCryptoFilter filter; + + private HttpServletRequest mockRequest; + private HttpServletResponse mockResponse; + + @BeforeAll + static void setUpClass() throws Exception { + mockService = mock(CryptoModuleService.class); + + ctx = new GenericApplicationContext(); + ctx.getBeanFactory().registerSingleton("cryptoModuleService", mockService); + ctx.registerBeanDefinition("applicationContextProvider", + BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class) + .getBeanDefinition()); + ctx.refresh(); + + filter = new BaseCryptoFilter(); + } + + @AfterAll + static void tearDownClass() { + if (ctx != null) ctx.close(); + } + + @BeforeEach + void setUp() throws Exception { + reset(mockService); + mockRequest = mock(HttpServletRequest.class); + mockResponse = mock(HttpServletResponse.class); + + when(mockService.decrypt(anyString(), anyMap(), any(), any(byte[].class))).thenReturn(PLAIN); + when(mockService.encrypt(anyString(), anyMap(), any(), any(byte[].class))).thenReturn(CIPHER); + } + + // ========================================================================= + // 1. buildRuntimeContext + // ========================================================================= + + @Test + @DisplayName("1-1. buildRuntimeContext — 항상 빈 Map 반환") + void testBuildRuntimeContext_returnsEmptyMap() { + Map result = filter.buildRuntimeContext(new Properties(), mockRequest); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + // ========================================================================= + // 2. buildAad + // ========================================================================= + + @Test + @DisplayName("2-1. buildAad — crypto.aad.header 미설정 → null") + void testBuildAad_noProp_returnsNull() { + assertNull(filter.buildAad(new Properties(), mockRequest)); + } + + @Test + @DisplayName("2-2. buildAad — 헤더명 설정, 헤더값 없음 → null") + void testBuildAad_headerNameSet_noValue_returnsNull() { + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Api-Request-Id"); + when(mockRequest.getHeader("X-Api-Request-Id")).thenReturn(null); + + assertNull(filter.buildAad(prop, mockRequest)); + } + + @Test + @DisplayName("2-3. buildAad — 헤더명 설정, 헤더값 있음 → UTF-8 바이트 반환") + void testBuildAad_headerNameSet_valuePresent_returnsByte() { + String headerVal = "req-id-abc123"; + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Api-Request-Id"); + when(mockRequest.getHeader("X-Api-Request-Id")).thenReturn(headerVal); + + assertArrayEquals(headerVal.getBytes(StandardCharsets.UTF_8), filter.buildAad(prop, mockRequest)); + } + + // ========================================================================= + // 3. doPreFilter — BODY scope 복호화 + // ========================================================================= + + @Test + @DisplayName("3-1. BODY scope — Base64 암호문 복호화 → 평문 반환") + void testPreFilter_body_decrypt_roundTrip() throws Exception { + Object result = filter.doPreFilter("g", "a", CIPHER_B64, bodyDecryptProps(), mockRequest, mockResponse); + + assertEquals(PLAIN_STR, result); + verify(mockService).decrypt(eq(MODULE), anyMap(), isNull(), eq(CIPHER)); + } + + @Test + @DisplayName("3-2. crypto.dec.enabled=N — 복호화 생략, message 그대로 반환") + void testPreFilter_decDisabled_passThrough() throws Exception { + Properties prop = bodyDecryptProps(); + prop.setProperty(CryptoFilter.PROP_DEC_ENABLED, "N"); + + Object result = filter.doPreFilter("g", "a", CIPHER_B64, prop, mockRequest, mockResponse); + + assertEquals(CIPHER_B64, result); + verifyNoInteractions(mockService); + } + + @Test + @DisplayName("3-3. crypto.module.name 누락 — IllegalArgumentException") + void testPreFilter_missingModuleName_throwsException() { + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_SCOPE, "BODY"); + + assertThrows(IllegalArgumentException.class, + () -> filter.doPreFilter("g", "a", CIPHER_B64, prop, mockRequest, mockResponse)); + } + + @Test + @DisplayName("3-4. message가 byte[] — UTF-8 디코딩 후 처리") + void testPreFilter_body_byteArrayMessage() throws Exception { + Object result = filter.doPreFilter("g", "a", CIPHER_B64.getBytes(StandardCharsets.UTF_8), + bodyDecryptProps(), mockRequest, mockResponse); + + assertEquals(PLAIN_STR, result); + } + + // ========================================================================= + // 4. doPreFilter — FIELD scope 복호화 + // ========================================================================= + + @Test + @DisplayName("4-1. FIELD scope — fromPath 복호화 → toPath 반영") + void testPreFilter_field_decryptToPath() throws Exception { + String body = "{\"enc\":\"" + CIPHER_B64 + "\",\"result\":\"\"}"; + Properties prop = fieldDecryptProps("/enc", "/result"); + + Object result = filter.doPreFilter("g", "a", body, prop, mockRequest, mockResponse); + + assertTrue(result.toString().contains("\"result\":\"" + PLAIN_STR + "\""), + "toPath에 복호화된 값이 반영되어야 한다"); + verify(mockService).decrypt(eq(MODULE), anyMap(), isNull(), eq(CIPHER)); + } + + @Test + @DisplayName("4-2. FIELD scope, toPath='/' — 복호화된 텍스트로 body 전체 교체") + void testPreFilter_field_decryptToRoot() throws Exception { + String body = "{\"enc\":\"" + CIPHER_B64 + "\"}"; + Properties prop = fieldDecryptProps("/enc", "/"); + + Object result = filter.doPreFilter("g", "a", body, prop, mockRequest, mockResponse); + + assertEquals(PLAIN_STR, result, "toPath='/'이면 body 전체가 복호화된 텍스트로 교체되어야 한다"); + } + + @Test + @DisplayName("4-3. FIELD scope — fromPath 값 없음 → body 그대로 반환") + void testPreFilter_field_missingFromPathValue_passThrough() throws Exception { + String body = "{\"other\":\"value\"}"; + Properties prop = fieldDecryptProps("/enc", "/other"); + + Object result = filter.doPreFilter("g", "a", body, prop, mockRequest, mockResponse); + + assertEquals(body, result); + verifyNoInteractions(mockService); + } + + // ========================================================================= + // 5. doPostFilter — BODY scope 암호화 + // ========================================================================= + + @Test + @DisplayName("5-1. BODY scope, crypto.enc.enabled=Y — 평문 암호화 → Base64 반환") + void testPostFilter_body_encrypt() throws Exception { + String body = "plain-response-body"; + Object result = filter.doPostFilter("g", "a", body, bodyEncryptProps(), mockRequest, mockResponse); + + assertEquals(CIPHER_B64, result); + verify(mockService).encrypt(eq(MODULE), anyMap(), isNull(), eq(body.getBytes(StandardCharsets.UTF_8))); + } + + @Test + @DisplayName("5-2. crypto.enc.enabled=N(기본) — 암호화 생략, message 그대로 반환") + void testPostFilter_encDisabled_passThrough() throws Exception { + String body = "plain-response-body"; + Object result = filter.doPostFilter("g", "a", body, bodyDecryptProps(), mockRequest, mockResponse); + + assertEquals(body, result); + verifyNoInteractions(mockService); + } + + // ========================================================================= + // 6. doPostFilter — FIELD scope 암호화 + // ========================================================================= + + @Test + @DisplayName("6-1. FIELD scope — fromPath 평문 암호화 → toPath Base64 반영") + void testPostFilter_field_encryptToPath() throws Exception { + String body = "{\"plain\":\"hello\",\"enc\":\"\"}"; + Properties prop = fieldEncryptProps("/plain", "/enc"); + + Object result = filter.doPostFilter("g", "a", body, prop, mockRequest, mockResponse); + + assertTrue(result.toString().contains("\"enc\":\"" + CIPHER_B64 + "\""), + "toPath에 Base64 암호문이 반영되어야 한다"); + } + + @Test + @DisplayName("6-2. FIELD scope, fromPath='/' — body 전체 암호화 → toPath 필드명으로 래핑") + void testPostFilter_field_encryptFromRoot() throws Exception { + String body = "{\"plain\":\"hello\"}"; + Properties prop = fieldEncryptProps("/", "/encrypted"); + + Object result = filter.doPostFilter("g", "a", body, prop, mockRequest, mockResponse); + + assertEquals("{\"encrypted\":\"" + CIPHER_B64 + "\"}", result); + } + + @Test + @DisplayName("6-3. FIELD scope — fromPath 값 없음 → body 그대로 반환") + void testPostFilter_field_missingFromPathValue_passThrough() throws Exception { + String body = "{\"other\":\"value\"}"; + Properties prop = fieldEncryptProps("/plain", "/enc"); + + Object result = filter.doPostFilter("g", "a", body, prop, mockRequest, mockResponse); + + assertEquals(body, result); + verifyNoInteractions(mockService); + } + + // ========================================================================= + // 7. AAD 헤더 연동 + // ========================================================================= + + @Test + @DisplayName("7-1. doPreFilter BODY — crypto.aad.header 설정 시 헤더값을 AAD로 전달") + void testPreFilter_body_aadFromHeader_passedToService() throws Exception { + String aadVal = "request-id-xyz"; + Properties prop = bodyDecryptProps(); + prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Api-Request-Id"); + when(mockRequest.getHeader("X-Api-Request-Id")).thenReturn(aadVal); + + filter.doPreFilter("g", "a", CIPHER_B64, prop, mockRequest, mockResponse); + + verify(mockService).decrypt(eq(MODULE), anyMap(), + eq(aadVal.getBytes(StandardCharsets.UTF_8)), eq(CIPHER)); + } + + @Test + @DisplayName("7-2. doPostFilter BODY — crypto.aad.header 미설정 시 aad=null로 전달") + void testPostFilter_body_noAadHeader_nullAadPassedToService() throws Exception { + filter.doPostFilter("g", "a", "plain", bodyEncryptProps(), mockRequest, mockResponse); + + verify(mockService).encrypt(eq(MODULE), anyMap(), isNull(), any(byte[].class)); + } + + @Test + @DisplayName("7-3. doPreFilter FIELD — crypto.aad.header 설정 시 헤더값을 AAD로 전달") + void testPreFilter_field_aadFromHeader_passedToService() throws Exception { + String aadVal = "field-aad-value"; + String body = "{\"enc\":\"" + CIPHER_B64 + "\",\"result\":\"\"}"; + Properties prop = fieldDecryptProps("/enc", "/result"); + prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Api-Aad"); + when(mockRequest.getHeader("X-Api-Aad")).thenReturn(aadVal); + + filter.doPreFilter("g", "a", body, prop, mockRequest, mockResponse); + + verify(mockService).decrypt(eq(MODULE), anyMap(), + eq(aadVal.getBytes(StandardCharsets.UTF_8)), eq(CIPHER)); + } + + // ========================================================================= + // 헬퍼 + // ========================================================================= + + private Properties bodyDecryptProps() { + Properties p = new Properties(); + p.setProperty(CryptoFilter.PROP_MODULE_NAME, MODULE); + p.setProperty(CryptoFilter.PROP_SCOPE, "BODY"); + return p; + } + + private Properties bodyEncryptProps() { + Properties p = bodyDecryptProps(); + p.setProperty(CryptoFilter.PROP_ENC_ENABLED, "Y"); + return p; + } + + private Properties fieldDecryptProps(String fromPath, String toPath) { + Properties p = new Properties(); + p.setProperty(CryptoFilter.PROP_MODULE_NAME, MODULE); + p.setProperty(CryptoFilter.PROP_SCOPE, "FIELD"); + p.setProperty(CryptoFilter.PROP_DEC_FROM_PATH, fromPath); + p.setProperty(CryptoFilter.PROP_DEC_TO_PATH, toPath); + return p; + } + + private Properties fieldEncryptProps(String fromPath, String toPath) { + Properties p = new Properties(); + p.setProperty(CryptoFilter.PROP_MODULE_NAME, MODULE); + p.setProperty(CryptoFilter.PROP_SCOPE, "FIELD"); + p.setProperty(CryptoFilter.PROP_ENC_ENABLED, "Y"); + p.setProperty(CryptoFilter.PROP_ENC_FROM_PATH, fromPath); + p.setProperty(CryptoFilter.PROP_ENC_TO_PATH, toPath); + return p; + } +} diff --git a/src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java b/src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java index dd90d1d..ae5278d 100644 --- a/src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java +++ b/src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java @@ -54,11 +54,13 @@ class CryptoModuleServiceTest { 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)); + 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); @@ -200,6 +202,124 @@ class CryptoModuleServiceTest { "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 = "AAD null 기본 동작 테스트".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); + } + + @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 = "DYNAMIC GCM AAD 테스트".getBytes(StandardCharsets.UTF_8); + + byte[] encrypted = service.encrypt("DYN_GCM_SVC", runtimeCtx, aad, plain); + byte[] decrypted = service.decrypt("DYN_GCM_SVC", runtimeCtx, aad, encrypted); + + 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 = "DYNAMIC GCM AAD null 테스트".getBytes(StandardCharsets.UTF_8); + + byte[] encrypted = service.encrypt("DYN_GCM_SVC", runtimeCtx, null, plain); + byte[] decrypted = service.decrypt("DYN_GCM_SVC", runtimeCtx, null, encrypted); + + 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는 무시되고 정상 암복호화되어야 한다"); + } + // ========================================================================= // 헬퍼 // =========================================================================