From 3cda2873ccc553fbd827876644fabfa085f13809 Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 12 May 2026 16:27:21 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20HTTP=20=EC=96=B4=EB=8C=91=ED=84=B0?= =?UTF-8?q?=20=EC=95=94=EB=B3=B5=ED=98=B8=ED=99=94=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8=20scope=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BaseCryptoFilter: 빈 RuntimeContext를 반환하는 기반 클래스 추가 - CryptoFilter: 기본 scope SCOPE_BODY → SCOPE_FIELD로 변경 Co-Authored-By: Claude Sonnet 4.6 --- .../http/dynamic/filter/BaseCryptoFilter.java | 21 +++++++++++++++++++ .../http/dynamic/filter/CryptoFilter.java | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/eactive/eai/adapter/http/dynamic/filter/BaseCryptoFilter.java diff --git a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/BaseCryptoFilter.java b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/BaseCryptoFilter.java new file mode 100644 index 0000000..40dad14 --- /dev/null +++ b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/BaseCryptoFilter.java @@ -0,0 +1,21 @@ +package com.eactive.eai.adapter.http.dynamic.filter; + +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import javax.servlet.http.HttpServletRequest; + +/** + * HTTP 어댑터 요청/응답 암복호화 필터 기반 클래스. + * + */ +public class BaseCryptoFilter extends CryptoFilter { + /** + * 빈 Runtime Context를 생성한다. + */ + protected Map buildRuntimeContext(Properties prop, HttpServletRequest request) { + return new HashMap<>(); + } + +} 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 38ad7d1..7261abd 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 @@ -76,7 +76,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter { } String moduleName = required(prop, PROP_MODULE_NAME); - String scope = prop.getProperty(PROP_SCOPE, SCOPE_BODY).toUpperCase(); + String scope = prop.getProperty(PROP_SCOPE, SCOPE_FIELD).toUpperCase(); String body = toBodyString(message); Map runtimeCtx = buildRuntimeContext(prop, request); @@ -101,7 +101,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter { } String moduleName = required(prop, PROP_MODULE_NAME); - String scope = prop.getProperty(PROP_SCOPE, SCOPE_BODY).toUpperCase(); + String scope = prop.getProperty(PROP_SCOPE, SCOPE_FIELD).toUpperCase(); String body = toBodyString(resultMessage); Map runtimeCtx = buildRuntimeContext(prop, request); From f4c28e8a272711bbeea54c347c94727c2f6c395e Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 12 May 2026 16:30:26 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20HSM=20=ED=82=A4=20=ED=8C=8C?= =?UTF-8?q?=EC=83=9D=20=EC=A0=84=EB=9E=B5=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(common=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BaseHsmKeyDerivationStrategy: HSM 마스터키 조회 공통 로직 추상 기반 클래스 - HsmKeyDerivationStrategy: HSM 마스터키 직접 사용 전략 - HsmSha256KeyDerivationStrategy: HSM 마스터키 SHA-256 해싱 도출 전략 - HsmContextSha256KeyDerivationStrategy: HSM 마스터키 + 컨텍스트 SHA-256 해싱 도출 전략 Co-Authored-By: Claude Sonnet 4.6 --- .../BaseHsmKeyDerivationStrategy.java | 40 ++++++++++++ ...HsmContextSha256KeyDerivationStrategy.java | 65 +++++++++++++++++++ .../strategy/HsmKeyDerivationStrategy.java | 24 +++++++ .../HsmSha256KeyDerivationStrategy.java | 30 +++++++++ 4 files changed, 159 insertions(+) create mode 100644 src/main/java/com/eactive/eai/common/security/keyderiv/strategy/BaseHsmKeyDerivationStrategy.java create mode 100644 src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmContextSha256KeyDerivationStrategy.java create mode 100644 src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmKeyDerivationStrategy.java create mode 100644 src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmSha256KeyDerivationStrategy.java diff --git a/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/BaseHsmKeyDerivationStrategy.java b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/BaseHsmKeyDerivationStrategy.java new file mode 100644 index 0000000..e0d9c58 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/BaseHsmKeyDerivationStrategy.java @@ -0,0 +1,40 @@ +package com.eactive.eai.common.security.keyderiv.strategy; + +import java.util.Map; + +import javax.crypto.SecretKey; + +import com.eactive.eai.common.hsm.HsmCryptoService; +import com.eactive.eai.common.hsm.HsmException; +import com.eactive.eai.common.security.keyderiv.KeyDerivationStrategy; +import com.eactive.eai.common.util.ApplicationContextProvider; + +/** + * HSM 마스터키를 이용하는 전략 클래스들의 기본 클래스 + */ +public abstract class BaseHsmKeyDerivationStrategy implements KeyDerivationStrategy { + + protected static final String PARAM_HSM_KEY_ALIAS = "hsmKeyAlias"; + + protected byte[] getHsmKey(Map params) throws HsmException { + String hsmKeyAlias = required(params, PARAM_HSM_KEY_ALIAS); + return getHsmKey(hsmKeyAlias); + } + + protected byte[] getHsmKey(String hsmKeyAlias) throws HsmException { + SecretKey masterKey = hsmCryptoService().getSecretKey(hsmKeyAlias); + return masterKey.getEncoded(); + } + + 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/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmContextSha256KeyDerivationStrategy.java b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmContextSha256KeyDerivationStrategy.java new file mode 100644 index 0000000..4fe6a39 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmContextSha256KeyDerivationStrategy.java @@ -0,0 +1,65 @@ +package com.eactive.eai.common.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 { + + 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/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmKeyDerivationStrategy.java b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmKeyDerivationStrategy.java new file mode 100644 index 0000000..aa84439 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmKeyDerivationStrategy.java @@ -0,0 +1,24 @@ +package com.eactive.eai.common.security.keyderiv.strategy; + +import java.util.Map; + +import com.eactive.eai.common.security.keyderiv.DerivedKey; + +/** + * HSM 마스터키를 사용하는 전략 + * + * key_deriv_params (JSON) 예시: { "hsmKeyAlias" : "MASTER_KEY_AES"} + */ +public class HsmKeyDerivationStrategy extends BaseHsmKeyDerivationStrategy { + + @Override + public DerivedKey deriveKey(Map params, Map runtimeContext) throws Exception { + byte[] masterKeyBytes = super.getHsmKey(params); + return new DerivedKey(masterKeyBytes, masterKeyBytes); + } + + @Override + public String buildCacheKey(String cryptoName, Map params, Map runtimeContext) { + return cryptoName; + } +} diff --git a/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmSha256KeyDerivationStrategy.java b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmSha256KeyDerivationStrategy.java new file mode 100644 index 0000000..9fc7ac5 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmSha256KeyDerivationStrategy.java @@ -0,0 +1,30 @@ +package com.eactive.eai.common.security.keyderiv.strategy; + +import java.security.MessageDigest; +import java.util.Map; + +import com.eactive.eai.common.security.keyderiv.DerivedKey; + +/** + * HSM 마스터키를 SHA-256 해싱으로 키를 도출하는 전략. + * SHA-256 출력은 항상 32바이트(AES-256)이므로 별도 keyLength 파라미터가 불필요하다. + * + * key_deriv_params (JSON) 예시: + * { + * "hsmKeyAlias" : "MASTER_KEY_AES" + * } + */ +public class HsmSha256KeyDerivationStrategy extends BaseHsmKeyDerivationStrategy { + + @Override + public DerivedKey deriveKey(Map params, Map runtimeContext) throws Exception { + byte[] masterKeyBytes = super.getHsmKey(params); + byte[] derived = MessageDigest.getInstance("SHA-256").digest(masterKeyBytes); + return new DerivedKey(derived, derived); + } + + @Override + public String buildCacheKey(String cryptoName, Map params, Map runtimeContext) { + return cryptoName; + } +} From 085dd024d8fb4ce874fb3a069ccbbfb81ac86c7e Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 12 May 2026 16:37:15 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20HsmContextSha256KeyDerivationStrate?= =?UTF-8?q?gyTest=20common=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- ...ontextSha256KeyDerivationStrategyTest.java | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/test/java/com/eactive/eai/common/security/keyderiv/HsmContextSha256KeyDerivationStrategyTest.java diff --git a/src/test/java/com/eactive/eai/common/security/keyderiv/HsmContextSha256KeyDerivationStrategyTest.java b/src/test/java/com/eactive/eai/common/security/keyderiv/HsmContextSha256KeyDerivationStrategyTest.java new file mode 100644 index 0000000..b16da5c --- /dev/null +++ b/src/test/java/com/eactive/eai/common/security/keyderiv/HsmContextSha256KeyDerivationStrategyTest.java @@ -0,0 +1,179 @@ +package com.eactive.eai.common.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.security.keyderiv.strategy.HsmContextSha256KeyDerivationStrategy; +import com.eactive.eai.common.util.ApplicationContextProvider; + +/** + * 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); + } +} From 444b7ad595c2c8bc6f2cdf57e33c8d76d9d68edd Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 12 May 2026 18:15:40 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20CryptoFilter=20GCM=20AAD=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=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는 무시되고 정상 암복호화되어야 한다"); + } + // ========================================================================= // 헬퍼 // ========================================================================= From e208a2d64b035202f1f1c94dc5251820e1e1170c Mon Sep 17 00:00:00 2001 From: curry772 Date: Wed, 13 May 2026 13:29:02 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20CryptoFilter=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=8D=BC=ED=8B=B0=20=ED=82=A4=20=EA=B7=9C=EA=B2=A9=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20HSM=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CryptoFilter prop 키를 UPPER_UNDERSCORE 형식으로 변경 (CRYPTO_MODULE_NAME 등) - CRYPTO_DEC_ENABLED / CRYPTO_ENC_ENABLED 옵션 제거 (필터 설정 시 항상 암복호화) - getProp() 빈 문자열도 기본값으로 처리 (StringUtils.isBlank 적용) - CryptoFilterHsmIntegrationTest 추가: SoftHSM2 연동 전체 흐름 검증 (7개 케이스) - attributes(*, CKO_SECRET_KEY, CKK_AES): 임포트 키도 CKA_SENSITIVE=false 적용 - HsmKeyDerivationStrategy / HsmContextSha256KeyDerivationStrategy 검증 - HsmKeyRegisterUtil 추가: SoftHSM2 마스터키 등록 유틸리티 - CryptoModuleServiceTest: GCM AAD 관련 테스트 케이스 보강 Co-Authored-By: Claude Sonnet 4.6 --- .../http/dynamic/filter/CryptoFilter.java | 68 ++- .../filter/HttpAdapterFilterFactoryKjb.java | 6 +- .../dynamic/filter/BaseCryptoFilterTest.java | 39 +- .../CryptoFilterHsmIntegrationTest.java | 464 ++++++++++++++++++ .../eai/common/hsm/HsmKeyRegisterUtil.java | 158 ++++++ .../security/CryptoModuleServiceTest.java | 49 +- 6 files changed, 704 insertions(+), 80 deletions(-) create mode 100644 src/test/java/com/eactive/eai/adapter/http/dynamic/filter/CryptoFilterHsmIntegrationTest.java create mode 100644 src/test/java/com/eactive/eai/common/hsm/HsmKeyRegisterUtil.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 a4b1fc6..e8424b7 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 @@ -23,40 +23,37 @@ import com.eactive.eai.util.JsonPathUtil; * (CryptoModuleManager가 KEY_SOURCE_TYPE=STATIC이면 runtimeContext를 무시하고 고정 키를 사용한다.) * * 어댑터 프로퍼티 키: - * crypto.module.name 필수. DB에 등록된 암호화 모듈명. - * crypto.scope BODY | FIELD (기본 BODY) + * CRYPTO_MODULE_NAME 필수. DB에 등록된 암호화 모듈명. + * CRYPTO_SCOPE BODY | FIELD (기본 FIELD) * BODY : 메시지 전체를 암복호화 (Base64 인코딩/디코딩) * FIELD : 특정 JSON 경로의 값만 암복호화 - * crypto.dec.enabled Y | N (기본 Y). doPreFilter(요청) 복호화 수행 여부. - * crypto.enc.enabled Y | N (기본 N). doPostFilter(응답) 암호화 수행 여부. * * FIELD 범위 전용: - * crypto.dec.from.path 복호화 대상 JSON 경로 (예: /encryptedData) - * crypto.dec.to.path 복호화 결과 반영 경로. "/" = 전체 body 교체. - * crypto.enc.from.path 암호화 대상 JSON 경로. "/" = 전체 body 암호화. - * crypto.enc.to.path 암호화 결과(Base64)를 넣을 JSON 경로 (예: /encryptedData) + * CRYPTO_DEC_FROM_PATH 복호화 대상 JSON 경로 (예: /encrypted_data) + * CRYPTO_DEC_TO_PATH 복호화 결과 반영 경로. "/" = 전체 body 교체. + * CRYPTO_ENC_FROM_PATH 암호화 대상 JSON 경로. "/" = 전체 body 암호화. + * CRYPTO_ENC_TO_PATH 암호화 결과(Base64)를 넣을 JSON 경로 (예: /encrypted_data) * * GCM AAD 전용 (선택): - * crypto.aad.header AAD로 사용할 HTTP 요청 헤더명 (예: X-Api-Request-Id). + * 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_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"; + public static final String PROP_MODULE_NAME = "CRYPTO_MODULE_NAME"; + public static final String PROP_SCOPE = "CRYPTO_SCOPE"; + 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"; - protected static final String PATH_ROOT = "/"; + public static final String SCOPE_BODY = "BODY"; + public static final String SCOPE_FIELD = "FIELD"; + public static final String PATH_ROOT = "/"; + public static final String PATH_ENCDATA = "/encrypted_data"; // ------------------------------------------------------------------ // 추상 메서드 @@ -76,20 +73,16 @@ public abstract class CryptoFilter implements HttpAdapterFilter { public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop, HttpServletRequest request, HttpServletResponse response) throws Exception { - if (!isEnabled(prop, PROP_DEC_ENABLED, "Y")) { - return message; - } - String moduleName = required(prop, PROP_MODULE_NAME); - String scope = prop.getProperty(PROP_SCOPE, SCOPE_FIELD).toUpperCase(); + String scope = getProp(prop, 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, aad, - required(prop, PROP_DEC_FROM_PATH), - prop.getProperty(PROP_DEC_TO_PATH, PATH_ROOT)); + getProp(prop, PROP_DEC_FROM_PATH, PATH_ENCDATA), + getProp(prop, PROP_DEC_TO_PATH, PATH_ROOT)); } return decryptBody(body, moduleName, runtimeCtx, aad); } @@ -102,20 +95,16 @@ public abstract class CryptoFilter implements HttpAdapterFilter { public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop, HttpServletRequest request, HttpServletResponse response) throws Exception { - if (!isEnabled(prop, PROP_ENC_ENABLED, "N")) { - return resultMessage; - } - String moduleName = required(prop, PROP_MODULE_NAME); - String scope = prop.getProperty(PROP_SCOPE, SCOPE_FIELD).toUpperCase(); + String scope = getProp(prop, 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, aad, - prop.getProperty(PROP_ENC_FROM_PATH, PATH_ROOT), - required(prop, PROP_ENC_TO_PATH)); + getProp(prop, PROP_ENC_FROM_PATH, PATH_ROOT), + getProp(prop, PROP_ENC_TO_PATH, PATH_ENCDATA)); } return encryptBody(body, moduleName, runtimeCtx, aad); } @@ -228,10 +217,6 @@ public abstract class CryptoFilter implements HttpAdapterFilter { 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)); - } - protected String required(Properties prop, String key) { String v = prop.getProperty(key); if (StringUtils.isBlank(v)) { @@ -239,4 +224,9 @@ public abstract class CryptoFilter implements HttpAdapterFilter { } return v; } + + private String getProp(Properties prop, String key, String defaultVal) { + String v = prop.getProperty(key); + return StringUtils.isBlank(v) ? defaultVal : v; + } } diff --git a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/HttpAdapterFilterFactoryKjb.java b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/HttpAdapterFilterFactoryKjb.java index 1b3f0c1..dc5c770 100644 --- a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/HttpAdapterFilterFactoryKjb.java +++ b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/HttpAdapterFilterFactoryKjb.java @@ -6,7 +6,8 @@ import com.eactive.eai.common.util.Logger; public class HttpAdapterFilterFactoryKjb extends HttpAdapterFilterFactory { static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER); - static String basePackage = "com.eactive.eai.custom.adapter.http.dynamic.filter"; + static String basePackage = "com.eactive.eai.adapter.http.dynamic.filter"; + static String customBasePackage = "com.eactive.eai.custom.adapter.http.dynamic.filter"; static private ConcurrentHashMap h = new ConcurrentHashMap(); public HttpAdapterFilterFactoryKjb() { @@ -44,6 +45,9 @@ public class HttpAdapterFilterFactoryKjb extends HttpAdapterFilterFactory { if (filter == null) { filter = classForName(basePackage + "." + type); } + if (filter == null) { + filter = classForName(customBasePackage + "." + type); + } break; } 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 index 243ca28..e75423d 100644 --- 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 @@ -92,7 +92,7 @@ class BaseCryptoFilterTest { // ========================================================================= @Test - @DisplayName("2-1. buildAad — crypto.aad.header 미설정 → null") + @DisplayName("2-1. buildAad — CRYPTO_AAD_HEADER 미설정 → null") void testBuildAad_noProp_returnsNull() { assertNull(filter.buildAad(new Properties(), mockRequest)); } @@ -132,19 +132,7 @@ class BaseCryptoFilterTest { } @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") + @DisplayName("3-3. CRYPTO_MODULE_NAME 누락 — IllegalArgumentException") void testPreFilter_missingModuleName_throwsException() { Properties prop = new Properties(); prop.setProperty(CryptoFilter.PROP_SCOPE, "BODY"); @@ -207,7 +195,7 @@ class BaseCryptoFilterTest { // ========================================================================= @Test - @DisplayName("5-1. BODY scope, crypto.enc.enabled=Y — 평문 암호화 → Base64 반환") + @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); @@ -216,16 +204,6 @@ class BaseCryptoFilterTest { 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 암호화 // ========================================================================= @@ -270,7 +248,7 @@ class BaseCryptoFilterTest { // ========================================================================= @Test - @DisplayName("7-1. doPreFilter BODY — crypto.aad.header 설정 시 헤더값을 AAD로 전달") + @DisplayName("7-1. doPreFilter BODY — CRYPTO_AAD_HEADER 설정 시 헤더값을 AAD로 전달") void testPreFilter_body_aadFromHeader_passedToService() throws Exception { String aadVal = "request-id-xyz"; Properties prop = bodyDecryptProps(); @@ -284,7 +262,7 @@ class BaseCryptoFilterTest { } @Test - @DisplayName("7-2. doPostFilter BODY — crypto.aad.header 미설정 시 aad=null로 전달") + @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); @@ -292,7 +270,7 @@ class BaseCryptoFilterTest { } @Test - @DisplayName("7-3. doPreFilter FIELD — crypto.aad.header 설정 시 헤더값을 AAD로 전달") + @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\":\"\"}"; @@ -318,9 +296,7 @@ class BaseCryptoFilterTest { } private Properties bodyEncryptProps() { - Properties p = bodyDecryptProps(); - p.setProperty(CryptoFilter.PROP_ENC_ENABLED, "Y"); - return p; + return bodyDecryptProps(); } private Properties fieldDecryptProps(String fromPath, String toPath) { @@ -336,7 +312,6 @@ class BaseCryptoFilterTest { 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/adapter/http/dynamic/filter/CryptoFilterHsmIntegrationTest.java b/src/test/java/com/eactive/eai/adapter/http/dynamic/filter/CryptoFilterHsmIntegrationTest.java new file mode 100644 index 0000000..6c9a439 --- /dev/null +++ b/src/test/java/com/eactive/eai/adapter/http/dynamic/filter/CryptoFilterHsmIntegrationTest.java @@ -0,0 +1,464 @@ +package com.eactive.eai.adapter.http.dynamic.filter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +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.hsm.HsmCryptoService; +import com.eactive.eai.common.hsm.HsmManager; +import com.eactive.eai.common.property.PropManager; +import com.eactive.eai.common.security.CryptoModuleConfigVO; +import com.eactive.eai.common.security.CryptoModuleManager; +import com.eactive.eai.common.security.CryptoModuleService; +import com.eactive.eai.common.security.keyderiv.strategy.HsmContextSha256KeyDerivationStrategy; +import com.eactive.eai.common.security.keyderiv.strategy.HsmKeyDerivationStrategy; +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; + +/** + * CryptoFilter HSM 연동 통합 테스트 (SoftHSM2 / SunPKCS11) + * + * SoftHSM2에서 MASTER_KEY를 조회하여 키를 도출한 뒤 + * BaseCryptoFilter / CryptoFilter 의 암복호화 전체 흐름을 검증한다. + * + * 사전 조건: + * C:\SoftHSM2 에 SoftHSM2 설치 (미설치 시 자동 건너뜀) + * MASTER_KEY alias는 없으면 자동 생성됨 + * + * 검증 전략: + * HSM → 키 도출 → CryptoModuleExtension → CryptoFilter doPostFilter(암호화) → doPreFilter(복호화) → 원문 일치 + */ +@TestMethodOrder(MethodOrderer.DisplayName.class) +public class CryptoFilterHsmIntegrationTest { + + // ------------------------------------------------------------------------- + // 상수 + // ------------------------------------------------------------------------- + + private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll"; + private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe"; + private static final String TOKEN_LABEL = "eapim-test"; + private static final String PIN = "1234"; + private static final String MASTER_KEY_ALIAS = "MASTER_KEY"; + + /** HsmKeyDerivationStrategy: HSM 마스터키를 파생 없이 직접 사용 */ + private static final String MOD_HSM_GCM = "HSM_GCM"; + /** HsmContextSha256KeyDerivationStrategy: HSM 마스터키 + 컨텍스트값 SHA-256 파생 */ + private static final String MOD_CTX_SHA_GCM = "CTX_SHA256_GCM"; + + private static final String CTX_KEY = "X-Api-Group-Seq"; + private static final String CTX_VAL_A = "GROUP-A"; + private static final String CTX_VAL_B = "GROUP-B"; + + // ------------------------------------------------------------------------- + // 정적 필드 + // ------------------------------------------------------------------------- + + private static GenericApplicationContext springCtx; + private static HsmManager hsmManager; + private static File tempCfgFile; + + // ------------------------------------------------------------------------- + // 내부 클래스: runtimeContext를 고정값으로 반환하는 필터 + // ------------------------------------------------------------------------- + + static class ContextAwareCryptoFilter extends CryptoFilter { + private final String key; + private final String value; + + ContextAwareCryptoFilter(String key, String value) { + this.key = key; + this.value = value; + } + + @Override + protected Map buildRuntimeContext(Properties prop, HttpServletRequest request) { + Map ctx = new HashMap<>(); + ctx.put(key, value); + return ctx; + } + } + + // ------------------------------------------------------------------------- + // 인스턴스 필드 + // ------------------------------------------------------------------------- + + private HttpServletRequest mockReq; + private HttpServletResponse mockRes; + + // ------------------------------------------------------------------------- + // 전체 설정 / 해제 + // ------------------------------------------------------------------------- + + @BeforeAll + static void setUpAll() throws Exception { + assumeTrue(new File(SOFTHSM2_DLL).exists(), + "SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀"); + System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf"); + + // 1. 토큰 초기화 + ensureTokenInitialized(); + + // 2. PKCS11 설정 문자열 + // attributes(*,...): generate/import 모두 CKA_SENSITIVE=false → getEncoded() 사용 가능 + String cfgContent = "name = SoftHSM\n" + + "library = " + SOFTHSM2_DLL + "\n" + + "slotListIndex = 0\n" + + "attributes(*, CKO_SECRET_KEY, CKK_AES) = {\n" + + " CKA_SENSITIVE = false\n" + + " CKA_EXTRACTABLE = true\n" + + "}\n"; + tempCfgFile = File.createTempFile("pkcs11-filter-it-", ".cfg"); + Files.write(tempCfgFile.toPath(), cfgContent.getBytes("UTF-8")); + + // 3. Mock PropManager (DB 없이 HSM 설정값 제공) + PropManager mockPropManager = mock(PropManager.class); + when(mockPropManager.getProperty("HSM", "PKCS11_CONFIG")).thenReturn(cfgContent); + when(mockPropManager.getProperty("HSM", "PIN")).thenReturn(PIN); + when(mockPropManager.getProperty(eq("HSM"), eq("CACHE_RELOAD_YN"), anyString())).thenReturn("N"); + + // 4. HsmManager (private 생성자 → 리플렉션) + Constructor hsmCtor = HsmManager.class.getDeclaredConstructor(); + hsmCtor.setAccessible(true); + hsmManager = hsmCtor.newInstance(); + + // 5. CryptoModuleManager (private 생성자 → 리플렉션) + CryptoModuleConfigLoader mockLoader = mock(CryptoModuleConfigLoader.class); + when(mockLoader.findAll()).thenReturn(buildModuleConfigs()); + + Constructor managerCtor = CryptoModuleManager.class.getDeclaredConstructor(); + managerCtor.setAccessible(true); + CryptoModuleManager manager = managerCtor.newInstance(); + + CryptoModuleService cryptoService = new CryptoModuleService(); + HsmCryptoService hsmCryptoService = new HsmCryptoService(); + + // 6. Spring ApplicationContext 구성 + springCtx = new GenericApplicationContext(); + springCtx.getBeanFactory().registerSingleton("propManager", mockPropManager); + springCtx.getBeanFactory().registerSingleton("hsmManager", hsmManager); + springCtx.getBeanFactory().registerSingleton("hsmCryptoService", hsmCryptoService); + springCtx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader); + springCtx.getBeanFactory().registerSingleton("cryptoModuleManager", manager); + springCtx.getBeanFactory().registerSingleton("cryptoModuleService", cryptoService); + springCtx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper()); + springCtx.registerBeanDefinition("applicationContextProvider", + BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class) + .getBeanDefinition()); + springCtx.refresh(); + + // 7. HsmManager 시작 (SunPKCS11 Provider 초기화 + KeyStore 오픈) + hsmManager.start(); + assertTrue(hsmManager.isReady(), "HsmManager 초기화 실패"); + System.out.println("[HSM] Provider: " + hsmManager.getPkcs11Provider().getName()); + + // 8. MASTER_KEY 등록 (없으면 자동 생성) + ensureMasterKey(); + + // 9. CryptoModuleManager 시작 (모듈 설정 로드) + manager.start(); + System.out.println("[Crypto] 모듈 로드 완료"); + } + + @AfterAll + static void tearDownAll() throws Exception { + if (hsmManager != null && hsmManager.isStarted()) hsmManager.stop(); + if (springCtx != null) springCtx.close(); + if (tempCfgFile != null) tempCfgFile.delete(); + } + + @BeforeEach + void setUp() { + mockReq = mock(HttpServletRequest.class); + mockRes = mock(HttpServletResponse.class); + } + + // ========================================================================= + // 1. HsmKeyDerivationStrategy — HSM 마스터키 직접 사용 (BaseCryptoFilter) + // ========================================================================= + + @Test + @DisplayName("1-1. FIELD/GCM HsmKey — doPostFilter 암호화 → doPreFilter 복호화 라운드트립") + void testHsmGcm_field_roundTrip() throws Exception { + BaseCryptoFilter filter = new BaseCryptoFilter(); + String original = "{\"acno\":\"1234567890\",\"amount\":\"100000\"}"; + + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM); + // SCOPE 기본값 FIELD, 경로 기본값 사용 + + // 암호화: body 전체 → /encrypted_data 필드 + String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes); + System.out.println("[1-1] encrypted: " + encrypted); + assertTrue(encrypted.contains("encrypted_data"), "/encrypted_data 필드가 있어야 한다"); + + // 복호화: /encrypted_data 필드 → body 전체 교체 + String decrypted = (String) filter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes); + System.out.println("[1-1] decrypted: " + decrypted); + assertEquals(original, decrypted); + } + + @Test + @DisplayName("1-2. BODY/GCM HsmKey — doPostFilter 암호화 → doPreFilter 복호화 라운드트립") + void testHsmGcm_body_roundTrip() throws Exception { + BaseCryptoFilter filter = new BaseCryptoFilter(); + String original = "{\"acno\":\"1234567890\",\"amount\":\"100000\"}"; + + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM); + prop.setProperty(CryptoFilter.PROP_SCOPE, "BODY"); + + // 암호화: body 전체 → Base64 암호문 + String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes); + System.out.println("[1-2] encrypted(Base64): " + encrypted); + assertNotEquals(original, encrypted, "암호문은 평문과 달라야 한다"); + + // 복호화: Base64 암호문 → 원문 + String decrypted = (String) filter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes); + System.out.println("[1-2] decrypted: " + decrypted); + assertEquals(original, decrypted); + } + + @Test + @DisplayName("1-3. FIELD/GCM HsmKey + AAD 헤더 — 동일 요청 ID로 암복호화 성공") + void testHsmGcm_field_withAad_success() throws Exception { + BaseCryptoFilter filter = new BaseCryptoFilter(); + String original = "{\"amount\":\"500000\"}"; + String requestId = "REQ-20240101-001"; + + when(mockReq.getHeader("X-Request-Id")).thenReturn(requestId); + + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM); + prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Request-Id"); + + String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes); + String decrypted = (String) filter.doPreFilter( "G", "A", encrypted, prop, mockReq, mockRes); + System.out.println("[1-3] decrypted: " + decrypted); + assertEquals(original, decrypted); + } + + @Test + @DisplayName("1-4. FIELD/GCM HsmKey + AAD 헤더 — 다른 요청 ID로 복호화 시 GCM 인증 실패") + void testHsmGcm_field_withAad_wrongAad_fails() throws Exception { + BaseCryptoFilter filter = new BaseCryptoFilter(); + String original = "{\"amount\":\"500000\"}"; + + // 암호화 시 요청 ID + HttpServletRequest encReq = mock(HttpServletRequest.class); + when(encReq.getHeader("X-Request-Id")).thenReturn("REQ-CORRECT"); + + // 복호화 시 다른 요청 ID + HttpServletRequest decReq = mock(HttpServletRequest.class); + when(decReq.getHeader("X-Request-Id")).thenReturn("REQ-WRONG"); + + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM); + prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Request-Id"); + + String encrypted = (String) filter.doPostFilter("G", "A", original, prop, encReq, mockRes); + + assertThrows(Exception.class, + () -> filter.doPreFilter("G", "A", encrypted, prop, decReq, mockRes), + "잘못된 AAD로 GCM 복호화 시 예외가 발생해야 한다"); + System.out.println("[1-4] 다른 AAD 복호화 예외 확인 완료"); + } + + // ========================================================================= + // 2. HsmContextSha256KeyDerivationStrategy — 컨텍스트 기반 SHA-256 파생 키 + // ========================================================================= + + @Test + @DisplayName("2-1. FIELD/GCM ContextSha256 — 동일 컨텍스트로 암복호화 라운드트립") + void testCtxSha256Gcm_field_roundTrip() throws Exception { + CryptoFilter filter = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_A); + String original = "{\"accNo\":\"0011234567890\",\"amount\":\"50000\"}"; + + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_CTX_SHA_GCM); + + String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes); + System.out.println("[2-1] encrypted: " + encrypted); + assertTrue(encrypted.contains("encrypted_data")); + + String decrypted = (String) filter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes); + System.out.println("[2-1] decrypted: " + decrypted); + assertEquals(original, decrypted); + } + + @Test + @DisplayName("2-2. FIELD/GCM ContextSha256 — 암호화와 다른 컨텍스트로 복호화 시 GCM 인증 실패") + void testCtxSha256Gcm_differentContext_throwsException() throws Exception { + CryptoFilter encFilter = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_A); + CryptoFilter decFilter = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_B); + String original = "{\"data\":\"sensitive\"}"; + + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_CTX_SHA_GCM); + + String encrypted = (String) encFilter.doPostFilter("G", "A", original, prop, mockReq, mockRes); + + assertThrows(Exception.class, + () -> decFilter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes), + "다른 컨텍스트 키로 파생된 키는 복호화에 실패해야 한다"); + System.out.println("[2-2] 다른 컨텍스트 복호화 예외 확인 완료"); + } + + @Test + @DisplayName("2-3. FIELD/GCM ContextSha256 — 컨텍스트별 독립 암복호화 (GROUP-A, GROUP-B 각각 성공)") + void testCtxSha256Gcm_perGroupRoundTrip() throws Exception { + CryptoFilter filterA = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_A); + CryptoFilter filterB = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_B); + String plainA = "{\"group\":\"A\",\"amount\":\"1000\"}"; + String plainB = "{\"group\":\"B\",\"amount\":\"2000\"}"; + + Properties prop = new Properties(); + prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_CTX_SHA_GCM); + + String encA = (String) filterA.doPostFilter("G", "A", plainA, prop, mockReq, mockRes); + String encB = (String) filterB.doPostFilter("G", "B", plainB, prop, mockReq, mockRes); + + assertEquals(plainA, (String) filterA.doPreFilter("G", "A", encA, prop, mockReq, mockRes)); + assertEquals(plainB, (String) filterB.doPreFilter("G", "B", encB, prop, mockReq, mockRes)); + System.out.println("[2-3] GROUP-A, GROUP-B 각각 독립 암복호화 성공"); + } + + // ========================================================================= + // 헬퍼 + // ========================================================================= + + private static List buildModuleConfigs() { + return Arrays.asList( + buildDynamic(MOD_HSM_GCM, "AES", "GCM", "NoPadding", + HsmKeyDerivationStrategy.class.getName(), + "{\"hsmKeyAlias\":\"" + MASTER_KEY_ALIAS + "\"}"), + buildDynamic(MOD_CTX_SHA_GCM, "AES", "GCM", "NoPadding", + HsmContextSha256KeyDerivationStrategy.class.getName(), + "{\"hsmKeyAlias\":\"" + MASTER_KEY_ALIAS + "\",\"contextKey\":\"" + CTX_KEY + "\"}") + ); + } + + private static CryptoModuleConfig buildDynamic(String name, String alg, String mode, String padding, + String strategy, String params) { + CryptoModuleConfig c = new CryptoModuleConfig(); + c.setCryptoId(UUID.randomUUID().toString()); + c.setCryptoName(name); + c.setAlgType(alg); + c.setCipherMode(mode); + c.setPadding(padding); + c.setKeySourceType("DYNAMIC"); + c.setKeyDerivStrategy(strategy); + c.setKeyDerivParams(params); + c.setCacheYn("Y"); + c.setCacheTtlSec(60); + c.setUseYn("Y"); + // ivHex = null: GCM에서 IV를 AAD 대체값으로 사용하지 않도록 의도적으로 미설정 + return c; + } + + private static void ensureMasterKey() throws Exception { + java.security.KeyStore ks = hsmManager.getKeyStore(); + + // 이전 실행 키 삭제 (sensitive 여부 무관하게 항상 고정키로 재등록) + if (ks.containsAlias(MASTER_KEY_ALIAS)) { + ks.deleteEntry(MASTER_KEY_ALIAS); + System.out.println("[HSM] 기존 MASTER_KEY 삭제"); + } + + // CryptoModuleServiceTest.KEY_128 과 동일한 고정 키 → 두 테스트 간 암호문 비교 가능 + // attributes(*, CKO_SECRET_KEY, CKK_AES) 지시어로 임포트 시 CKA_SENSITIVE=false 적용 + byte[] keyBytes = "0123456789abcdef".getBytes(StandardCharsets.UTF_8); + SecretKey fixedKey = new SecretKeySpec(keyBytes, "AES"); + ks.setKeyEntry(MASTER_KEY_ALIAS, fixedKey, null, null); + + // 임포트 후 getEncoded() 검증 + SecretKey stored = (SecretKey) ks.getKey(MASTER_KEY_ALIAS, null); + byte[] encoded = stored.getEncoded(); + System.out.println("[HSM] MASTER_KEY 등록 완료: alias=" + MASTER_KEY_ALIAS + + " / getEncoded()=" + (encoded != null ? encoded.length + "bytes, " + new String(encoded) : "null (추출 불가!)")); + } + + private static void ensureTokenInitialized() throws Exception { + ProcessBuilder pb = new ProcessBuilder( + SOFTHSM2_UTIL, "--init-token", "--free", + "--label", TOKEN_LABEL, "--pin", PIN, "--so-pin", PIN); + pb.redirectErrorStream(true); + Process p = pb.start(); + String out = readOutput(p); + p.waitFor(10, TimeUnit.SECONDS); + System.out.println("[SoftHSM2] init-token: " + out.trim()); + } + + 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; + } + }; + } + + private static String readOutput(Process p) throws Exception { + InputStream is = p.getInputStream(); + byte[] buf = new byte[4096]; + StringBuilder sb = new StringBuilder(); + int len; + while ((len = is.read(buf)) != -1) { + sb.append(new String(buf, 0, len, "UTF-8")); + } + return sb.toString(); + } +} diff --git a/src/test/java/com/eactive/eai/common/hsm/HsmKeyRegisterUtil.java b/src/test/java/com/eactive/eai/common/hsm/HsmKeyRegisterUtil.java new file mode 100644 index 0000000..c9958cc --- /dev/null +++ b/src/test/java/com/eactive/eai/common/hsm/HsmKeyRegisterUtil.java @@ -0,0 +1,158 @@ +package com.eactive.eai.common.hsm; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.Provider; +import java.security.Security; +import java.util.Enumeration; +import java.util.concurrent.TimeUnit; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +/** + * SoftHSM2 마스터키 등록 유틸리티 테스트. + * + * 용도: CryptoModuleConfig.keyDerivParams 의 hsmKeyAlias 에 대응하는 + * AES-256 마스터키를 SoftHSM2 토큰에 등록한다. + * + * 사전 조건: + * C:\SoftHSM2 에 SoftHSM2 설치 + * (토큰이 없으면 자동 초기화, 있으면 기존 토큰 재사용) + * + * 실행 후 확인: + * softhsm2-util.exe --show-slots + * 또는 pkcs11-tool --list-objects (OpenSC 설치 시) + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class HsmKeyRegisterUtil { + + private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll"; + private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe"; + private static final String TOKEN_LABEL = "eapim-test"; + private static final String PIN = "1234"; + + /** DB CryptoModuleConfig.keyDerivParams.hsmKeyAlias 와 반드시 일치해야 한다. */ + private static final String MASTER_KEY_ALIAS = "MASTER_KEY"; + + private static Provider provider; + private static KeyStore keyStore; + + @BeforeAll + static void setUp() throws Exception { + System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf"); + assumeTrue(new File(SOFTHSM2_DLL).exists(), + "SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀"); + + ensureTokenInitialized(); + + String cfgContent = "name = SoftHSM\n" + + "library = " + SOFTHSM2_DLL + "\n" + + "slotListIndex = 0\n"; + + provider = HsmManager.createProvider(cfgContent); + Provider existing = Security.getProvider(provider.getName()); + if (existing != null) Security.removeProvider(existing.getName()); + Security.addProvider(provider); + + keyStore = KeyStore.getInstance("PKCS11", provider); + keyStore.load(null, PIN.toCharArray()); + } + + // ------------------------------------------------------------------ + // 1. 현재 등록된 키 목록 출력 + // ------------------------------------------------------------------ + + @Test + @Order(1) + void listKeys() throws Exception { + System.out.println("=== HSM 키 목록 ==="); + Enumeration aliases = keyStore.aliases(); + if (!aliases.hasMoreElements()) { + System.out.println(" (등록된 키 없음)"); + } + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + String type = keyStore.isKeyEntry(alias) ? "KEY" : "CERT"; + System.out.println(" [" + type + "] " + alias); + } + System.out.println("=================="); + } + + // ------------------------------------------------------------------ + // 2. 마스터키 등록 (없는 경우에만) + // ------------------------------------------------------------------ + + @Test + @Order(2) + void registerMasterKey() throws Exception { + if (keyStore.containsAlias(MASTER_KEY_ALIAS)) { + System.out.println("[SKIP] 이미 등록됨: " + MASTER_KEY_ALIAS); + return; + } + + KeyGenerator kg = KeyGenerator.getInstance("AES", provider); + kg.init(256); + SecretKey sk = kg.generateKey(); + keyStore.setKeyEntry(MASTER_KEY_ALIAS, sk, null, null); + + System.out.println("[OK] AES-256 마스터키 등록 완료: alias=" + MASTER_KEY_ALIAS); + } + + // ------------------------------------------------------------------ + // 3. 등록 후 조회 확인 + // ------------------------------------------------------------------ + + @Test + @Order(3) + void verifyMasterKey() throws Exception { + keyStore.load(null, PIN.toCharArray()); // 갱신 + + assert keyStore.containsAlias(MASTER_KEY_ALIAS) + : "마스터키 등록 실패: " + MASTER_KEY_ALIAS; + + SecretKey sk = (SecretKey) keyStore.getKey(MASTER_KEY_ALIAS, null); + assert sk != null : "키 조회 실패"; + assert "AES".equals(sk.getAlgorithm()) : "알고리즘 불일치: " + sk.getAlgorithm(); + + System.out.println("[OK] 마스터키 조회 성공: alias=" + MASTER_KEY_ALIAS + + " / algorithm=" + sk.getAlgorithm()); + } + + // ------------------------------------------------------------------ + // 헬퍼 + // ------------------------------------------------------------------ + + private static void ensureTokenInitialized() throws Exception { + ProcessBuilder pb = new ProcessBuilder( + SOFTHSM2_UTIL, "--init-token", "--free", + "--label", TOKEN_LABEL, + "--pin", PIN, + "--so-pin", PIN); + pb.redirectErrorStream(true); + Process p = pb.start(); + String out = readOutput(p); + p.waitFor(10, TimeUnit.SECONDS); + System.out.println("[SoftHSM2] init-token: " + out.trim()); + } + + private static String readOutput(Process p) throws Exception { + InputStream is = p.getInputStream(); + byte[] buf = new byte[4096]; + StringBuilder sb = new StringBuilder(); + int len; + while ((len = is.read(buf)) != -1) { + sb.append(new String(buf, 0, len, "UTF-8")); + } + return sb.toString(); + } +} 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 ae5278d..7c7fca5 100644 --- a/src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java +++ b/src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java @@ -6,6 +6,7 @@ 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; @@ -52,6 +53,7 @@ class CryptoModuleServiceTest { 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"); @@ -221,12 +223,22 @@ class CryptoModuleServiceTest { @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[] 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 @@ -266,11 +278,22 @@ class CryptoModuleServiceTest { 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[] 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); } @@ -280,11 +303,21 @@ class CryptoModuleServiceTest { Map runtimeCtx = new HashMap<>(); runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001"); - byte[] plain = "DYNAMIC GCM AAD null 테스트".getBytes(StandardCharsets.UTF_8); - + 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); } @@ -366,8 +399,8 @@ class CryptoModuleServiceTest { 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.setKeyDerivStrategy("com.eactive.eai.common.security.keyderiv.strategy.HsmKeyDerivationStrategy"); + c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\"}"); c.setCacheYn("Y"); c.setCacheTtlSec(300); c.setUseYn("Y");