diff --git a/elink-online-common b/elink-online-common index 8f70451..5806868 160000 --- a/elink-online-common +++ b/elink-online-common @@ -1 +1 @@ -Subproject commit 8f704514773530c4a42fb0d22b82fc95423dd25d +Subproject commit 580686886c0dbcbe7bbd8165e027f0b6777acfd2 diff --git a/elink-online-core-jpa b/elink-online-core-jpa index 76342bf..03aa7ce 160000 --- a/elink-online-core-jpa +++ b/elink-online-core-jpa @@ -1 +1 @@ -Subproject commit 76342bfaef2578ce322791c9f9a862dd98fb3010 +Subproject commit 03aa7ced90ee145177aaa9b60e407ea538f7a58f diff --git a/src/main/java/com/eactive/eai/custom/adapter/http/dynamic/filter/DJBErpCryptoFilter.java b/src/main/java/com/eactive/eai/custom/adapter/http/dynamic/filter/DJBErpCryptoFilter.java new file mode 100644 index 0000000..992bae0 --- /dev/null +++ b/src/main/java/com/eactive/eai/custom/adapter/http/dynamic/filter/DJBErpCryptoFilter.java @@ -0,0 +1,82 @@ +package com.eactive.eai.custom.adapter.http.dynamic.filter; + +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import javax.servlet.http.HttpServletRequest; + +import org.apache.commons.lang3.StringUtils; + +import com.eactive.eai.adapter.http.dynamic.filter.CryptoFilter; +import com.eactive.eai.util.JsonPathUtil; + +/** + * DJB ERP 연동용 암복호화 필터. + * + * DYNAMIC 키 컨텍스트 추출 규칙: + * HTTP 헤더 {@code httpHeaderGroup}의 값은 JSON 객체이며, + * 그 중 {@code x-emp-nm} 필드 값을 {@code groupSeq}로 + * runtimeContext에 담아 키 도출 전략에 전달한다. + * + * 어댑터 프로퍼티 (부모 클래스 공통 설정 외 추가 키): + * crypto.context.key runtimeContext에 넣을 키 이름 (기본: groupSeq) + * crypto.context.header 컨텍스트 JSON이 담긴 HTTP 헤더명 (기본: httpHeaderGroup) + * crypto.context.header.field 헤더 JSON에서 추출할 필드명 (기본: x-emp-nm) + * + * 어댑터 프로퍼티 설정 예: + * crypto.module.name = AES_GCM_NoPadding_DYNAMIC_sample + * crypto.scope = FIELD + * crypto.dec.enabled = Y + * crypto.enc.enabled = N + * crypto.dec.from.path = /encryptedData + * crypto.dec.to.path = / + * crypto.context.key = groupSeq + * crypto.context.header = httpHeaderGroup + * crypto.context.header.field = x-emp-nm + * + * 필터 타입 등록 (FQCN): + * com.eactive.eai.custom.adapter.http.dynamic.filter.DJBErpCryptoFilter + */ +public class DJBErpCryptoFilter extends CryptoFilter { + + public static final String PROP_CONTEXT_KEY = "crypto.context.key"; + public static final String PROP_CONTEXT_HEADER = "crypto.context.header"; + public static final String PROP_CONTEXT_HEADER_FIELD = "crypto.context.header.field"; + + private static final String DEFAULT_CONTEXT_KEY = "groupSeq"; + private static final String DEFAULT_CONTEXT_HEADER = "httpHeaderGroup"; + private static final String DEFAULT_CONTEXT_HEADER_FIELD = "x-emp-nm"; + + /** + * httpHeaderGroup 헤더(JSON)에서 x-emp-nm 값을 추출하여 groupSeq로 매핑한다. + * 헤더가 없거나 필드를 찾을 수 없으면 빈 Map을 반환한다. + */ + @Override + protected Map buildRuntimeContext(Properties prop, HttpServletRequest request) { + Map ctx = new HashMap<>(); + + String contextKey = prop.getProperty(PROP_CONTEXT_KEY, DEFAULT_CONTEXT_KEY); + String contextHeaderName = prop.getProperty(PROP_CONTEXT_HEADER, DEFAULT_CONTEXT_HEADER); + String contextHeaderField = prop.getProperty(PROP_CONTEXT_HEADER_FIELD, DEFAULT_CONTEXT_HEADER_FIELD); + + String headerJson = request.getHeader(contextHeaderName); + if (StringUtils.isBlank(headerJson)) { + logger.warn("DJBErpCryptoFilter] 컨텍스트 헤더 없음: " + contextHeaderName); + return ctx; + } + + String value = JsonPathUtil.getValueAtPath(headerJson, "/" + contextHeaderField); + if (value == null) { + logger.warn("DJBErpCryptoFilter] 헤더 JSON에서 필드 추출 실패: header=" + + contextHeaderName + ", field=" + contextHeaderField); + return ctx; + } + + // x-emp-nm 값은 "groupSeq|empNo" 형태 — '|' 앞부분만 groupSeq로 사용 + String groupSeq = value.contains("|") ? value.substring(0, value.indexOf('|')) : value; + ctx.put(contextKey, groupSeq); + + return ctx; + } +} diff --git a/src/main/java/com/eactive/eai/custom/security/keyderiv/strategy/HsmContextSha256KeyDerivationStrategy.java b/src/main/java/com/eactive/eai/custom/security/keyderiv/strategy/HsmContextSha256KeyDerivationStrategy.java new file mode 100644 index 0000000..2f720fe --- /dev/null +++ b/src/main/java/com/eactive/eai/custom/security/keyderiv/strategy/HsmContextSha256KeyDerivationStrategy.java @@ -0,0 +1,68 @@ +package com.eactive.eai.custom.security.keyderiv.strategy; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; + +import javax.crypto.SecretKey; + +import com.eactive.eai.common.hsm.HsmCryptoService; +import com.eactive.eai.common.security.keyderiv.DerivedKey; +import com.eactive.eai.common.security.keyderiv.KeyDerivationStrategy; +import com.eactive.eai.common.util.ApplicationContextProvider; + +/** + * HSM 마스터키 + 런타임 컨텍스트값을 연결(concatenate)한 뒤 SHA-256 해싱으로 키를 도출하는 전략. + * SHA-256 출력은 항상 32바이트(AES-256)이므로 별도 keyLength 파라미터가 불필요하다. + * + * key_deriv_params (JSON) 예시: + * { + * "hsmKeyAlias" : "MASTER_KEY_AES", + * "contextKey" : "X-Api-Group-Seq" -- runtimeContext에서 꺼낼 키 이름 + * } + */ +public class HsmContextSha256KeyDerivationStrategy implements KeyDerivationStrategy { + + /** DB key_deriv_strategy 컬럼에 사용하는 FQCN 참조용 상수. */ + public static final String STRATEGY_CLASS = HsmContextSha256KeyDerivationStrategy.class.getName(); + + private static final String PARAM_HSM_KEY_ALIAS = "hsmKeyAlias"; + private static final String PARAM_CONTEXT_KEY = "contextKey"; + + @Override + public DerivedKey deriveKey(Map params, Map runtimeContext) throws Exception { + String hsmKeyAlias = required(params, PARAM_HSM_KEY_ALIAS); + String contextKey = required(params, PARAM_CONTEXT_KEY); + + SecretKey masterKey = hsmCryptoService().getSecretKey(hsmKeyAlias); + byte[] masterKeyBytes = masterKey.getEncoded(); + byte[] contextBytes = runtimeContext.getOrDefault(contextKey, "") + .getBytes(StandardCharsets.UTF_8); + + byte[] combined = new byte[masterKeyBytes.length + contextBytes.length]; + System.arraycopy(masterKeyBytes, 0, combined, 0, masterKeyBytes.length); + System.arraycopy(contextBytes, 0, combined, masterKeyBytes.length, contextBytes.length); + + byte[] derived = MessageDigest.getInstance("SHA-256").digest(combined); + return new DerivedKey(derived, derived); + } + + @Override + public String buildCacheKey(String cryptoName, Map params, Map runtimeContext) { + String contextKey = params.getOrDefault(PARAM_CONTEXT_KEY, ""); + String contextValue = runtimeContext.getOrDefault(contextKey, ""); + return cryptoName + ":" + contextValue; + } + + private String required(Map params, String key) { + String value = params.get(key); + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("key_deriv_params 필수값 누락: " + key); + } + return value; + } + + private HsmCryptoService hsmCryptoService() { + return ApplicationContextProvider.getContext().getBean(HsmCryptoService.class); + } +} diff --git a/src/test/java/com/eactive/eai/custom/adapter/http/dynamic/filter/DJBErpCryptoFilterTest.java b/src/test/java/com/eactive/eai/custom/adapter/http/dynamic/filter/DJBErpCryptoFilterTest.java new file mode 100644 index 0000000..a423e0b --- /dev/null +++ b/src/test/java/com/eactive/eai/custom/adapter/http/dynamic/filter/DJBErpCryptoFilterTest.java @@ -0,0 +1,317 @@ +package com.eactive.eai.custom.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 org.mockito.ArgumentCaptor; + +import javax.servlet.http.HttpServletRequest; + +import org.json.simple.JSONObject; +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; + +@TestMethodOrder(MethodOrderer.DisplayName.class) +class DJBErpCryptoFilterTest { + + private static final String MODULE_NAME = "TEST_MODULE"; + // 복호화 후 평문 (JSON 문자열) + private static final String PLAIN_TEXT = "{\"userId\":\"U001\",\"name\":\"test\"}"; + private static final byte[] PLAIN_BYTES = PLAIN_TEXT.getBytes(StandardCharsets.UTF_8); + // 더미 암호문 bytes + Base64 표현 + private static final byte[] CIPHER_BYTES = new byte[]{0x10, 0x20, 0x30, 0x40}; + private static final String ENC_BASE64 = Base64.getEncoder().encodeToString(CIPHER_BYTES); + + // 기본 httpHeaderGroup 헤더 JSON (groupSeq|empNo 형태) + private static final String HEADER_JSON_WITH_PIPE = "{\"x-emp-nm\":\"G001|E001\"}"; + private static final String HEADER_JSON_WITHOUT_PIPE = "{\"x-emp-nm\":\"G001\"}"; + + private static GenericApplicationContext ctx; + private static CryptoModuleService mockCryptoService; + + private DJBErpCryptoFilter filter; + private HttpServletRequest mockRequest; + private Properties prop; + + // ========================================================================= + // 클래스 초기화 — Spring ApplicationContext + Mock CryptoModuleService + // ========================================================================= + + @BeforeAll + static void setUpClass() throws Exception { + mockCryptoService = mock(CryptoModuleService.class); + + ctx = new GenericApplicationContext(); + ctx.getBeanFactory().registerSingleton("cryptoModuleService", mockCryptoService); + ctx.registerBeanDefinition("applicationContextProvider", + BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class) + .getBeanDefinition()); + ctx.refresh(); + + when(mockCryptoService.decrypt(anyString(), anyMap(), any(byte[].class))) + .thenReturn(PLAIN_BYTES); + when(mockCryptoService.encrypt(anyString(), anyMap(), any(byte[].class))) + .thenReturn(CIPHER_BYTES); + } + + @AfterAll + static void tearDownClass() { + if (ctx != null) ctx.close(); + } + + @BeforeEach + void setUp() { + clearInvocations(mockCryptoService); // 이전 테스트의 호출 이력 초기화 (stubbing은 유지) + filter = new DJBErpCryptoFilter(); + mockRequest = mock(HttpServletRequest.class); + prop = new Properties(); + prop.setProperty(DJBErpCryptoFilter.PROP_MODULE_NAME, MODULE_NAME); + } + + // ========================================================================= + // 1. buildRuntimeContext + // ========================================================================= + + @Test + @DisplayName("1-1. x-emp-nm이 'groupSeq|empNo' 형태이면 '|' 앞부분만 groupSeq로 추출") + void buildRuntimeContext_pipeFormat_extractsGroupSeqOnly() { + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE); + + Map runtimeCtx = filter.buildRuntimeContext(prop, mockRequest); + + assertEquals("G001", runtimeCtx.get("groupSeq")); + assertEquals(1, runtimeCtx.size()); + } + + @Test + @DisplayName("1-2. x-emp-nm에 '|'가 없으면 전체 값을 groupSeq로 사용") + void buildRuntimeContext_noPipe_usesFullValue() { + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITHOUT_PIPE); + + Map runtimeCtx = filter.buildRuntimeContext(prop, mockRequest); + + assertEquals("G001", runtimeCtx.get("groupSeq")); + } + + @Test + @DisplayName("1-3. httpHeaderGroup 헤더가 없으면 빈 Map 반환") + void buildRuntimeContext_headerMissing_returnsEmptyMap() { + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(null); + + Map runtimeCtx = filter.buildRuntimeContext(prop, mockRequest); + + assertTrue(runtimeCtx.isEmpty()); + } + + @Test + @DisplayName("1-4. 헤더 JSON에 x-emp-nm 필드가 없으면 빈 Map 반환") + void buildRuntimeContext_fieldMissingInJson_returnsEmptyMap() { + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn("{\"other-field\":\"value\"}"); + + Map runtimeCtx = filter.buildRuntimeContext(prop, mockRequest); + + assertTrue(runtimeCtx.isEmpty()); + } + + @Test + @DisplayName("1-5. 프로퍼티로 헤더명/필드명/컨텍스트키를 커스터마이징할 수 있다") + void buildRuntimeContext_customProps_overrideDefaults() { + prop.setProperty(DJBErpCryptoFilter.PROP_CONTEXT_HEADER, "X-Custom-Header"); + prop.setProperty(DJBErpCryptoFilter.PROP_CONTEXT_HEADER_FIELD, "emp-id"); + prop.setProperty(DJBErpCryptoFilter.PROP_CONTEXT_KEY, "empGroup"); + + when(mockRequest.getHeader("X-Custom-Header")).thenReturn("{\"emp-id\":\"GRP99|EMP99\"}"); + + Map runtimeCtx = filter.buildRuntimeContext(prop, mockRequest); + + assertEquals("GRP99", runtimeCtx.get("empGroup")); + } + + // ========================================================================= + // 2. doPreFilter — 복호화 + // ========================================================================= + + @Test + @DisplayName("2-1. crypto.dec.enabled=N 이면 message를 그대로 반환하고 복호화하지 않는다") + void doPreFilter_decDisabled_returnsOriginalWithoutDecrypt() throws Exception { + prop.setProperty("crypto.dec.enabled", "N"); + String original = "{\"encryptedData\":\"" + ENC_BASE64 + "\"}"; + + Object result = filter.doPreFilter(null, null, original, prop, mockRequest, null); + + assertSame(original, result); + verifyNoInteractions(mockCryptoService); + } + + @Test + @DisplayName("2-2. FIELD scope: /encryptedData 복호화 후 dec.to.path=/ 이면 body 전체를 평문으로 교체") + void doPreFilter_fieldScope_toRoot_replacesEntireBody() throws Exception { + setFieldDecryptProps("/encryptedData", "/"); + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE); + + String body = "{\"encryptedData\":\"" + ENC_BASE64 + "\"}"; + Object result = filter.doPreFilter(null, null, body, prop, mockRequest, null); + + assertEquals(PLAIN_TEXT, result); + } + + @Test + @DisplayName("2-3. FIELD scope: dec.to.path가 특정 경로이면 해당 필드에 복호화 결과를 설정") + void doPreFilter_fieldScope_toSpecificPath_setsFieldInBody() throws Exception { + setFieldDecryptProps("/encryptedData", "/plainData"); + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE); + + String body = "{\"encryptedData\":\"" + ENC_BASE64 + "\"}"; + String result = (String) filter.doPreFilter(null, null, body, prop, mockRequest, null); + + assertTrue(result.contains("\"encryptedData\""), "원본 필드가 유지되어야 한다"); + assertTrue(result.contains("\"plainData\""), "복호화 결과 필드가 추가되어야 한다"); + } + + @Test + @DisplayName("2-4. FIELD scope: 복호화 대상 필드가 JSON에 없으면 원본 body를 그대로 반환") + void doPreFilter_fieldScope_missingEncryptedField_returnsOriginalBody() throws Exception { + setFieldDecryptProps("/encryptedData", "/"); + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE); + + String body = "{\"otherField\":\"value\"}"; + Object result = filter.doPreFilter(null, null, body, prop, mockRequest, null); + + assertEquals(body, result); + } + + @Test + @DisplayName("2-5. BODY scope: message가 Base64 String이면 전체 복호화") + void doPreFilter_bodyScope_stringMessage_decryptsWholeBody() throws Exception { + prop.setProperty("crypto.scope", "BODY"); + + Object result = filter.doPreFilter(null, null, ENC_BASE64, prop, mockRequest, null); + + assertEquals(PLAIN_TEXT, result); + } + + @Test + @DisplayName("2-6. message가 byte[]이면 UTF-8 변환 후 FIELD 복호화 처리") + void doPreFilter_fieldScope_byteArrayMessage_convertsAndDecrypts() throws Exception { + setFieldDecryptProps("/encryptedData", "/"); + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE); + + byte[] bodyBytes = ("{\"encryptedData\":\"" + ENC_BASE64 + "\"}").getBytes(StandardCharsets.UTF_8); + + Object result = filter.doPreFilter(null, null, bodyBytes, prop, mockRequest, null); + + assertEquals(PLAIN_TEXT, result); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("2-7. message가 JSONObject이면 toString() 변환 후 FIELD 복호화 처리") + void doPreFilter_fieldScope_jsonObjectMessage_convertsAndDecrypts() throws Exception { + setFieldDecryptProps("/encryptedData", "/"); + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE); + + JSONObject jsonMsg = new JSONObject(); + jsonMsg.put("encryptedData", ENC_BASE64); + + Object result = filter.doPreFilter(null, null, jsonMsg, prop, mockRequest, null); + + assertEquals(PLAIN_TEXT, result); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("2-8. FIELD scope: runtimeContext에 groupSeq가 올바르게 전달된다") + void doPreFilter_fieldScope_runtimeContextPassedToDecrypt() throws Exception { + setFieldDecryptProps("/encryptedData", "/"); + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE); + + String body = "{\"encryptedData\":\"" + ENC_BASE64 + "\"}"; + filter.doPreFilter(null, null, body, prop, mockRequest, null); + + ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockCryptoService).decrypt(eq(MODULE_NAME), ctxCaptor.capture(), any(byte[].class)); + assertEquals("G001", ctxCaptor.getValue().get("groupSeq")); + } + + // ========================================================================= + // 3. doPostFilter — 암호화 + // ========================================================================= + + @Test + @DisplayName("3-1. crypto.enc.enabled 기본값(N) — resultMessage를 그대로 반환하고 암호화하지 않는다") + void doPostFilter_encDisabledByDefault_returnsOriginalWithoutEncrypt() throws Exception { + String original = "{\"result\":\"ok\"}"; + + Object result = filter.doPostFilter(null, null, original, prop, mockRequest, null); + + assertSame(original, result); + verifyNoInteractions(mockCryptoService); + } + + @Test + @DisplayName("3-2. BODY scope, enc.enabled=Y — body 전체를 암호화하여 Base64 반환") + void doPostFilter_bodyScope_encEnabled_returnsBase64() throws Exception { + prop.setProperty("crypto.scope", "BODY"); + prop.setProperty("crypto.enc.enabled", "Y"); + + Object result = filter.doPostFilter(null, null, PLAIN_TEXT, prop, mockRequest, null); + + assertEquals(ENC_BASE64, result); + } + + @Test + @DisplayName("3-3. FIELD scope, enc.from.path=/ — body 전체 암호화 후 enc.to.path 필드로 래핑") + void doPostFilter_fieldScope_fromRoot_wrapsEncryptedInJson() throws Exception { + prop.setProperty("crypto.scope", "FIELD"); + prop.setProperty("crypto.enc.enabled", "Y"); + prop.setProperty("crypto.enc.from.path", "/"); + prop.setProperty("crypto.enc.to.path", "/encryptedData"); + + Object result = filter.doPostFilter(null, null, PLAIN_TEXT, prop, mockRequest, null); + + assertEquals("{\"encryptedData\":\"" + ENC_BASE64 + "\"}", result); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("3-4. FIELD scope: runtimeContext에 groupSeq가 올바르게 전달된다") + void doPostFilter_fieldScope_runtimeContextPassedToEncrypt() throws Exception { + prop.setProperty("crypto.scope", "FIELD"); + prop.setProperty("crypto.enc.enabled", "Y"); + prop.setProperty("crypto.enc.from.path", "/"); + prop.setProperty("crypto.enc.to.path", "/encryptedData"); + when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE); + + filter.doPostFilter(null, null, PLAIN_TEXT, prop, mockRequest, null); + + ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockCryptoService).encrypt(eq(MODULE_NAME), ctxCaptor.capture(), any(byte[].class)); + assertEquals("G001", ctxCaptor.getValue().get("groupSeq")); + } + + // ========================================================================= + // 헬퍼 + // ========================================================================= + + private void setFieldDecryptProps(String fromPath, String toPath) { + prop.setProperty("crypto.scope", "FIELD"); + prop.setProperty("crypto.dec.from.path", fromPath); + prop.setProperty("crypto.dec.to.path", toPath); + } +} diff --git a/src/test/java/com/eactive/eai/custom/security/keyderiv/HsmContextSha256KeyDerivationStrategyTest.java b/src/test/java/com/eactive/eai/custom/security/keyderiv/HsmContextSha256KeyDerivationStrategyTest.java new file mode 100644 index 0000000..eace92e --- /dev/null +++ b/src/test/java/com/eactive/eai/custom/security/keyderiv/HsmContextSha256KeyDerivationStrategyTest.java @@ -0,0 +1,179 @@ +package com.eactive.eai.custom.security.keyderiv; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HashMap; +import java.util.Map; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +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.keyderiv.DerivedKey; +import com.eactive.eai.common.util.ApplicationContextProvider; +import com.eactive.eai.custom.security.keyderiv.strategy.HsmContextSha256KeyDerivationStrategy; + +/** + * HsmContextSha256KeyDerivationStrategy 단위 테스트 + */ +@TestMethodOrder(MethodOrderer.DisplayName.class) +class HsmContextSha256KeyDerivationStrategyTest { + + private static final String HSM_KEY_ALIAS = "MASTER_KEY_AES"; + private static final byte[] MASTER_KEY = "0123456789abcdef".getBytes(StandardCharsets.UTF_8); + + private static GenericApplicationContext ctx; + private static HsmCryptoService mockHsmCryptoService; + private static HsmContextSha256KeyDerivationStrategy strategy; + + private Map params; + private Map runtimeContext; + + @BeforeAll + static void setUpClass() throws Exception { + mockHsmCryptoService = mock(HsmCryptoService.class); + SecretKey mockSecretKey = new SecretKeySpec(MASTER_KEY, "AES"); + when(mockHsmCryptoService.getSecretKey(HSM_KEY_ALIAS)).thenReturn(mockSecretKey); + + ctx = new GenericApplicationContext(); + ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsmCryptoService); + ctx.registerBeanDefinition("applicationContextProvider", + BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class) + .getBeanDefinition()); + ctx.refresh(); + + strategy = new HsmContextSha256KeyDerivationStrategy(); + } + + @BeforeEach + void setUpParams() { + params = new HashMap<>(); + params.put("hsmKeyAlias", HSM_KEY_ALIAS); + params.put("contextKey", "X-Api-Group-Seq"); + + runtimeContext = new HashMap<>(); + runtimeContext.put("X-Api-Group-Seq", "GROUP_001"); + } + + // ========================================================================= + // 1. deriveKey + // ========================================================================= + + @Test + @DisplayName("1-1. 정상 파라미터 — DerivedKey 반환, 길이 32바이트(SHA-256 고정 출력)") + void testDeriveKey_validParams_returns32ByteKey() throws Exception { + DerivedKey dk = strategy.deriveKey(params, runtimeContext); + + assertNotNull(dk); + assertNotNull(dk.getEncKey()); + assertNotNull(dk.getDecKey()); + assertEquals(32, dk.getEncKey().length, "SHA-256 출력은 항상 32바이트여야 한다"); + } + + @Test + @DisplayName("1-2. SHA-256(masterKey || contextValue) 계산 결과 검증") + void testDeriveKey_sha256ResultIsCorrect() throws Exception { + DerivedKey dk = strategy.deriveKey(params, runtimeContext); + + byte[] contextBytes = "GROUP_001".getBytes(StandardCharsets.UTF_8); + byte[] combined = new byte[MASTER_KEY.length + contextBytes.length]; + System.arraycopy(MASTER_KEY, 0, combined, 0, MASTER_KEY.length); + System.arraycopy(contextBytes, 0, combined, MASTER_KEY.length, contextBytes.length); + byte[] expected = MessageDigest.getInstance("SHA-256").digest(combined); + + assertArrayEquals(expected, dk.getEncKey(), "SHA-256 해싱 결과가 일치해야 한다"); + } + + @Test + @DisplayName("1-3. 대칭 알고리즘 — encKey == decKey") + void testDeriveKey_encKeyEqualsDecKey() throws Exception { + DerivedKey dk = strategy.deriveKey(params, runtimeContext); + + assertArrayEquals(dk.getEncKey(), dk.getDecKey(), "대칭 방식이므로 encKey와 decKey가 동일해야 한다"); + } + + @Test + @DisplayName("1-4. runtimeContext 값 없음 — 빈 문자열로 처리") + void testDeriveKey_missingContextValue_emptyStringUsed() throws Exception { + runtimeContext.remove("X-Api-Group-Seq"); + DerivedKey dk = strategy.deriveKey(params, runtimeContext); + + byte[] expected = MessageDigest.getInstance("SHA-256").digest(MASTER_KEY); + + assertArrayEquals(expected, dk.getEncKey(), "컨텍스트 값 없으면 masterKey만 해싱해야 한다"); + } + + @Test + @DisplayName("1-5. 다른 runtimeContext 값 — 다른 DerivedKey 생성") + void testDeriveKey_differentContextValue_differentKey() throws Exception { + DerivedKey dk1 = strategy.deriveKey(params, runtimeContext); + + runtimeContext.put("X-Api-Group-Seq", "GROUP_002"); + DerivedKey dk2 = strategy.deriveKey(params, runtimeContext); + + assertFalse(java.util.Arrays.equals(dk1.getEncKey(), dk2.getEncKey()), + "컨텍스트 값이 다르면 도출된 키도 달라야 한다"); + } + + @Test + @DisplayName("1-6. 필수 파라미터 누락(hsmKeyAlias) — IllegalArgumentException") + void testDeriveKey_missingHsmKeyAlias_throwsException() { + params.remove("hsmKeyAlias"); + + assertThrows(IllegalArgumentException.class, + () -> strategy.deriveKey(params, runtimeContext), + "hsmKeyAlias 누락 시 IllegalArgumentException이 발생해야 한다"); + } + + @Test + @DisplayName("1-7. 필수 파라미터 누락(contextKey) — IllegalArgumentException") + void testDeriveKey_missingContextKey_throwsException() { + params.remove("contextKey"); + + assertThrows(IllegalArgumentException.class, + () -> strategy.deriveKey(params, runtimeContext), + "contextKey 누락 시 IllegalArgumentException이 발생해야 한다"); + } + + // ========================================================================= + // 2. buildCacheKey + // ========================================================================= + + @Test + @DisplayName("2-1. buildCacheKey — cryptoName:contextValue 형식") + void testBuildCacheKey_format() { + String cacheKey = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext); + + assertEquals("CRYPTO_A:GROUP_001", cacheKey); + } + + @Test + @DisplayName("2-2. buildCacheKey — contextKey 값 없으면 빈 문자열") + void testBuildCacheKey_missingContextValue_emptyString() { + runtimeContext.remove("X-Api-Group-Seq"); + String cacheKey = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext); + + assertEquals("CRYPTO_A:", cacheKey); + } + + @Test + @DisplayName("2-3. buildCacheKey — 다른 cryptoName은 다른 캐시 키") + void testBuildCacheKey_differentCryptoName_differentKey() { + String key1 = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext); + String key2 = strategy.buildCacheKey("CRYPTO_B", params, runtimeContext); + + assertNotEquals(key1, key2); + } +}