From bf1135f9ad9faed1bbf4b7fadf311b8024b54405 Mon Sep 17 00:00:00 2001 From: curry772 Date: Wed, 6 May 2026 16:49:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=94=ED=98=B8=ED=99=94=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=ED=94=84=EB=A0=88=EC=9E=84=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CryptoModuleExtension 인터페이스: BC 프로바이더, AAD, 편의 메서드 오버로드 추가 - AESCryptoModuleExtension: CBC/GCM/ECB/FF1 지원, GCM 랜덤 nonce, AAD 처리 - ARIACryptoModuleExtension: ARIA 알고리즘 지원 - CryptoModuleConfigVO: JPA Entity 분리를 위한 Lombok VO - CryptoModuleManager: VO 패턴 적용, FQCN 기반 전략 동적 로딩(Class.forName) - CryptoModuleService: STATIC/DYNAMIC × AAD 조합 8가지 오버로드 - CryptoModuleConfigMapper: MapStruct Entity→VO 매퍼 - KeyDerivationStrategy 인터페이스 및 DerivedKey - HsmContextXorKeyDerivationStrategy: HSM 마스터키 XOR 컨텍스트값 전략 - ReloadCryptoModuleCommand: 설정 런타임 재로드 커맨드 - build.gradle: BouncyCastle bcprov-jdk15on:1.70 의존성 추가 - 단위 테스트 전체 추가 Co-Authored-By: Claude Sonnet 4.6 --- build.gradle | 2 + .../security/ReloadCryptoModuleCommand.java | 19 + .../security/AESCryptoModuleExtension.java | 173 ++++++++ .../security/ARIACryptoModuleExtension.java | 68 +++ .../common/security/CryptoModuleConfigVO.java | 23 + .../security/CryptoModuleExtension.java | 52 +++ .../common/security/CryptoModuleManager.java | 244 +++++++++++ .../common/security/CryptoModuleService.java | 77 ++++ .../common/security/keyderiv/DerivedKey.java | 20 + .../keyderiv/KeyDerivationStrategy.java | 23 + .../HsmContextXorKeyDerivationStrategy.java | 80 ++++ .../mapper/CryptoModuleConfigMapper.java | 19 + .../AESCryptoModuleExtensionTest.java | 399 ++++++++++++++++++ .../ARIACryptoModuleExtensionTest.java | 154 +++++++ .../security/CryptoModuleManagerTest.java | 391 +++++++++++++++++ .../security/CryptoModuleServiceTest.java | 292 +++++++++++++ .../security/keyderiv/DerivedKeyTest.java | 39 ++ ...smContextXorKeyDerivationStrategyTest.java | 192 +++++++++ 18 files changed, 2267 insertions(+) create mode 100644 src/main/java/com/eactive/eai/agent/security/ReloadCryptoModuleCommand.java create mode 100644 src/main/java/com/eactive/eai/common/security/AESCryptoModuleExtension.java create mode 100644 src/main/java/com/eactive/eai/common/security/ARIACryptoModuleExtension.java create mode 100644 src/main/java/com/eactive/eai/common/security/CryptoModuleConfigVO.java create mode 100644 src/main/java/com/eactive/eai/common/security/CryptoModuleExtension.java create mode 100644 src/main/java/com/eactive/eai/common/security/CryptoModuleManager.java create mode 100644 src/main/java/com/eactive/eai/common/security/CryptoModuleService.java create mode 100644 src/main/java/com/eactive/eai/common/security/keyderiv/DerivedKey.java create mode 100644 src/main/java/com/eactive/eai/common/security/keyderiv/KeyDerivationStrategy.java create mode 100644 src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmContextXorKeyDerivationStrategy.java create mode 100644 src/main/java/com/eactive/eai/common/security/mapper/CryptoModuleConfigMapper.java create mode 100644 src/test/java/com/eactive/eai/common/security/AESCryptoModuleExtensionTest.java create mode 100644 src/test/java/com/eactive/eai/common/security/ARIACryptoModuleExtensionTest.java create mode 100644 src/test/java/com/eactive/eai/common/security/CryptoModuleManagerTest.java create mode 100644 src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java create mode 100644 src/test/java/com/eactive/eai/common/security/keyderiv/DerivedKeyTest.java create mode 100644 src/test/java/com/eactive/eai/common/security/keyderiv/HsmContextXorKeyDerivationStrategyTest.java diff --git a/build.gradle b/build.gradle index f1d0b88..1d80701 100644 --- a/build.gradle +++ b/build.gradle @@ -82,6 +82,8 @@ dependencies { api 'com.nimbusds:nimbus-jose-jwt:9.24.3' + api 'org.bouncycastle:bcprov-jdk15on:1.70' + api "io.undertow:undertow-servlet:${undertowVersion}" api 'org.java-websocket:Java-WebSocket:1.3.9' api 'javax.cache:cache-api:1.1.1' diff --git a/src/main/java/com/eactive/eai/agent/security/ReloadCryptoModuleCommand.java b/src/main/java/com/eactive/eai/agent/security/ReloadCryptoModuleCommand.java new file mode 100644 index 0000000..757233a --- /dev/null +++ b/src/main/java/com/eactive/eai/agent/security/ReloadCryptoModuleCommand.java @@ -0,0 +1,19 @@ +package com.eactive.eai.agent.security; + +import com.eactive.eai.agent.command.Command; +import com.eactive.eai.agent.command.CommandException; +import com.eactive.eai.common.security.CryptoModuleManager; + +public class ReloadCryptoModuleCommand extends Command { + + @Override + public Object execute() throws CommandException { + try { + String cryptoId = (String) args; + CryptoModuleManager.getInstance().reload(cryptoId); + return null; + } catch (Exception e) { + throw new CommandException(e.getMessage()); + } + } +} diff --git a/src/main/java/com/eactive/eai/common/security/AESCryptoModuleExtension.java b/src/main/java/com/eactive/eai/common/security/AESCryptoModuleExtension.java new file mode 100644 index 0000000..4ba3afa --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/AESCryptoModuleExtension.java @@ -0,0 +1,173 @@ +package com.eactive.eai.common.security; + +import java.nio.ByteBuffer; +import java.security.Key; +import java.security.SecureRandom; +import java.security.Security; +import java.security.spec.AlgorithmParameterSpec; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.bouncycastle.jcajce.spec.FPEParameterSpec; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +public class AESCryptoModuleExtension implements CryptoModuleExtension { + + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + private static final String DEFAULT_PROVIDER = "BC"; + + private Cipher encryptEngine; + private Cipher decryptEngine; + private String mode; + private String pad; + private byte[] iv; + private byte[] encKey; + private byte[] decKey; + /** 명시적 프로바이더. null이면 모든 모드에서 BC(DEFAULT_PROVIDER)를 사용한다. */ + private String provider; + + // ------------------------------------------------------------------ + // init — 모든 오버로드는 최종적으로 6+provider 버전에 위임한다 + // ------------------------------------------------------------------ + + @Override + public void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey) throws Exception { + init(alg, mode, pad, null, encKey, decKey, null); + } + + @Override + public void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey) throws Exception { + init(alg, mode, pad, iv, encKey, decKey, null); + } + + @Override + public void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey, String provider) throws Exception { + init(alg, mode, pad, null, encKey, decKey, provider); + } + + @Override + public void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey, String provider) throws Exception { + this.mode = mode; + this.pad = pad; + this.iv = iv; + this.encKey = encKey; + this.decKey = decKey; + this.provider = (provider != null && !provider.isEmpty()) ? provider : null; + + // GCM 포함 모든 모드에서 BC 프로바이더를 사용 — 기본값 BC + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + + if (!mode.equalsIgnoreCase("GCM")) { + String m = mode != null ? mode : ""; + String p = pad != null ? pad : ""; + String transformation = String.format("AES/%s/%s", m, p); + + Key encKeySpec = new SecretKeySpec(encKey, "AES"); + Key decKeySpec = new SecretKeySpec(decKey, "AES"); + AlgorithmParameterSpec param = iv != null ? new IvParameterSpec(iv) : null; + if (m.toLowerCase().contains("ff")) { + param = new FPEParameterSpec(256, iv); + } + + String prov = this.provider != null ? this.provider : DEFAULT_PROVIDER; + this.encryptEngine = Cipher.getInstance(transformation, prov); + this.decryptEngine = Cipher.getInstance(transformation, prov); + this.encryptEngine.init(Cipher.ENCRYPT_MODE, encKeySpec, param, (SecureRandom) null); + this.decryptEngine.init(Cipher.DECRYPT_MODE, decKeySpec, param, (SecureRandom) null); + } + } + + // ------------------------------------------------------------------ + // encrypt + // ------------------------------------------------------------------ + + @Override + public int encrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception { + return this.encryptEngine.doFinal(src, sindex, slength, dst, dindex); + } + + @Override + public byte[] encrypt(byte[] src, int sindex, int slength) throws Exception { + return encrypt(src, sindex, slength, null); + } + + /** + * AAD를 명시한 암호화. + * GCM 모드에서 aad != null 이면 해당 값을 사용하고, null 이면 iv를 AAD로 사용하는 기본 동작을 따른다. + * 비-GCM 모드는 AAD를 무시하고 사전 초기화된 엔진으로 처리한다. + */ + @Override + public byte[] encrypt(byte[] src, int sindex, int slength, byte[] aad) throws Exception { + if (this.mode.equalsIgnoreCase("GCM")) { + Key key = new SecretKeySpec(this.encKey, "AES"); + byte[] nonce = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(nonce); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, nonce); + + String prov = this.provider != null ? this.provider : DEFAULT_PROVIDER; + Cipher cipher = Cipher.getInstance(String.format("AES/%s/%s", this.mode, this.pad), prov); + cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec); + + byte[] effectiveAad = aad != null ? aad : this.iv; + if (effectiveAad != null) cipher.updateAAD(effectiveAad); + + byte[] cipherTextWithTag = cipher.doFinal(src, sindex, slength); + byte[] output = new byte[GCM_IV_LENGTH + cipherTextWithTag.length]; + System.arraycopy(nonce, 0, output, 0, GCM_IV_LENGTH); + System.arraycopy(cipherTextWithTag, 0, output, GCM_IV_LENGTH, cipherTextWithTag.length); + return output; + } + + int outLen = this.encryptEngine.getOutputSize(slength); + byte[] dst = new byte[outLen]; + int actual = encrypt(src, sindex, slength, dst, 0); + return ByteBuffer.allocate(actual).put(dst, 0, actual).array(); + } + + // ------------------------------------------------------------------ + // decrypt + // ------------------------------------------------------------------ + + @Override + public int decrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception { + return this.decryptEngine.doFinal(src, sindex, slength, dst, dindex); + } + + @Override + public byte[] decrypt(byte[] src, int sindex, int slength) throws Exception { + return decrypt(src, sindex, slength, null); + } + + /** + * AAD를 명시한 복호화. + * GCM 모드에서 aad != null 이면 해당 값을 사용하고, null 이면 iv를 AAD로 사용하는 기본 동작을 따른다. + * 비-GCM 모드는 AAD를 무시하고 사전 초기화된 엔진으로 처리한다. + */ + @Override + public byte[] decrypt(byte[] src, int sindex, int slength, byte[] aad) throws Exception { + if (this.mode.equalsIgnoreCase("GCM")) { + Key key = new SecretKeySpec(this.decKey, "AES"); + byte[] nonce = new byte[GCM_IV_LENGTH]; + System.arraycopy(src, sindex, nonce, 0, GCM_IV_LENGTH); + byte[] cipherTextWithTag = new byte[slength - GCM_IV_LENGTH]; + System.arraycopy(src, sindex + GCM_IV_LENGTH, cipherTextWithTag, 0, cipherTextWithTag.length); + + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, nonce); + String prov = this.provider != null ? this.provider : DEFAULT_PROVIDER; + Cipher cipher = Cipher.getInstance(String.format("AES/%s/%s", this.mode, this.pad), prov); + cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec); + + byte[] effectiveAad = aad != null ? aad : this.iv; + if (effectiveAad != null) cipher.updateAAD(effectiveAad); + + return cipher.doFinal(cipherTextWithTag); + } + return this.decryptEngine.doFinal(src, sindex, slength); + } +} diff --git a/src/main/java/com/eactive/eai/common/security/ARIACryptoModuleExtension.java b/src/main/java/com/eactive/eai/common/security/ARIACryptoModuleExtension.java new file mode 100644 index 0000000..6248f4e --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/ARIACryptoModuleExtension.java @@ -0,0 +1,68 @@ +package com.eactive.eai.common.security; + +import java.nio.ByteBuffer; +import java.security.Key; +import java.security.SecureRandom; +import java.security.Security; +import java.security.spec.AlgorithmParameterSpec; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.bouncycastle.jcajce.spec.FPEParameterSpec; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +public class ARIACryptoModuleExtension implements CryptoModuleExtension { + + private Cipher encryptEngine; + private Cipher decryptEngine; + + @Override + public void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey) throws Exception { + this.init("ARIA", mode, pad, (byte[]) null, encKey, decKey); + } + + @Override + public void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey) throws Exception { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + String m = mode != null ? mode : ""; + String p = pad != null ? pad : ""; + Key encKeySpec = new SecretKeySpec(encKey, "ARIA"); + Key decKeySpec = new SecretKeySpec(decKey, "ARIA"); + AlgorithmParameterSpec param = iv != null ? new IvParameterSpec(iv) : null; + if (m.toLowerCase().contains("ff")) { + param = new FPEParameterSpec(256, iv); + } + this.encryptEngine = Cipher.getInstance(String.format("ARIA/%s/%s", m, p)); + this.encryptEngine.init(Cipher.ENCRYPT_MODE, encKeySpec, param, (SecureRandom) null); + this.decryptEngine = Cipher.getInstance(String.format("ARIA/%s/%s", m, p)); + this.decryptEngine.init(Cipher.DECRYPT_MODE, decKeySpec, param, (SecureRandom) null); + } + + @Override + public int encrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception { + return this.encryptEngine.doFinal(src, sindex, slength, dst, dindex); + } + + @Override + public byte[] encrypt(byte[] src, int sindex, int slength) throws Exception { + int size = (slength + 17) / this.encryptEngine.getBlockSize() * this.encryptEngine.getBlockSize(); + size += (slength + 17) % this.encryptEngine.getBlockSize() == 0 ? 0 : this.encryptEngine.getBlockSize(); + byte[] dst = new byte[this.encryptEngine.getOutputSize(size)]; + size = this.encrypt(src, sindex, slength, dst, 0); + return ByteBuffer.allocate(size).put(dst, 0, size).array(); + } + + @Override + public int decrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception { + return this.decryptEngine.doFinal(src, sindex, slength, dst, dindex); + } + + @Override + public byte[] decrypt(byte[] src, int sindex, int slength) throws Exception { + return this.decryptEngine.doFinal(src, sindex, slength); + } +} diff --git a/src/main/java/com/eactive/eai/common/security/CryptoModuleConfigVO.java b/src/main/java/com/eactive/eai/common/security/CryptoModuleConfigVO.java new file mode 100644 index 0000000..7d214ba --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/CryptoModuleConfigVO.java @@ -0,0 +1,23 @@ +package com.eactive.eai.common.security; + +import lombok.Data; + +@Data +public class CryptoModuleConfigVO { + + String cryptoId; + String cryptoName; + String cryptoDesc; + String algType; + String cipherMode; + String padding; + String ivHex; + String keySourceType; + String encKeyHex; + String decKeyHex; + String keyDerivStrategy; + String keyDerivParams; + String cacheYn; + Integer cacheTtlSec; + String useYn; +} diff --git a/src/main/java/com/eactive/eai/common/security/CryptoModuleExtension.java b/src/main/java/com/eactive/eai/common/security/CryptoModuleExtension.java new file mode 100644 index 0000000..5904ccf --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/CryptoModuleExtension.java @@ -0,0 +1,52 @@ +package com.eactive.eai.common.security; + +public interface CryptoModuleExtension { + + void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey) throws Exception; + + void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey) throws Exception; + + /** 프로바이더를 명시한 초기화. 미지원 구현체는 provider를 무시하고 기본 동작한다. */ + default void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey, String provider) throws Exception { + init(alg, mode, pad, encKey, decKey); + } + + /** 프로바이더를 명시한 초기화 (IV 포함). 미지원 구현체는 provider를 무시하고 기본 동작한다. */ + default void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey, String provider) throws Exception { + init(alg, mode, pad, iv, encKey, decKey); + } + + int encrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception; + + byte[] encrypt(byte[] src, int sindex, int slength) throws Exception; + + /** AAD를 명시한 암호화. null이면 구현체 기본 동작(IV를 AAD로 사용하거나 AAD 없음)을 따른다. */ + default byte[] encrypt(byte[] src, int sindex, int slength, byte[] aad) throws Exception { + return encrypt(src, sindex, slength); + } + + default byte[] encrypt(byte[] src) throws Exception { + return encrypt(src, 0, src.length); + } + + default byte[] encrypt(byte[] src, byte[] aad) throws Exception { + return encrypt(src, 0, src.length, aad); + } + + int decrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception; + + byte[] decrypt(byte[] src, int sindex, int slength) throws Exception; + + /** AAD를 명시한 복호화. null이면 구현체 기본 동작을 따른다. */ + default byte[] decrypt(byte[] src, int sindex, int slength, byte[] aad) throws Exception { + return decrypt(src, sindex, slength); + } + + default byte[] decrypt(byte[] src) throws Exception { + return decrypt(src, 0, src.length); + } + + default byte[] decrypt(byte[] src, byte[] aad) throws Exception { + return decrypt(src, 0, src.length, aad); + } +} diff --git a/src/main/java/com/eactive/eai/common/security/CryptoModuleManager.java b/src/main/java/com/eactive/eai/common/security/CryptoModuleManager.java new file mode 100644 index 0000000..27625b5 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/CryptoModuleManager.java @@ -0,0 +1,244 @@ +package com.eactive.eai.common.security; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import javax.xml.bind.DatatypeConverter; + +import org.springframework.stereotype.Component; + +import com.eactive.eai.common.lifecycle.Lifecycle; +import com.eactive.eai.common.lifecycle.LifecycleException; +import com.eactive.eai.common.lifecycle.LifecycleListener; +import com.eactive.eai.common.lifecycle.LifecycleSupport; +import com.eactive.eai.common.security.keyderiv.DerivedKey; +import com.eactive.eai.common.security.keyderiv.KeyDerivationStrategy; +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.common.util.Logger; +import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +public class CryptoModuleManager implements Lifecycle { + + static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT); + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final ConcurrentHashMap configMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap strategyCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap dynamicKeyCache = new ConcurrentHashMap<>(); + + private boolean started; + private final LifecycleSupport lifecycle = new LifecycleSupport(this); + + private CryptoModuleManager() {} + + public static CryptoModuleManager getInstance() { + return ApplicationContextProvider.getContext().getBean(CryptoModuleManager.class); + } + + @Override + public void start() throws LifecycleException { + if (started) throw new LifecycleException("RECEAICRY001"); + lifecycle.fireLifecycleEvent(STARTING_EVENT, this); + try { + loadAll(); + } catch (Exception e) { + throw new LifecycleException("RECEAICRY002"); + } + started = true; + lifecycle.fireLifecycleEvent(STARTED_EVENT, this); + } + + @Override + public void stop() throws LifecycleException { + if (!started) throw new LifecycleException("RECEAICRY003"); + lifecycle.fireLifecycleEvent(STOPING_EVENT, this); + configMap.clear(); + dynamicKeyCache.clear(); + strategyCache.clear(); + started = false; + lifecycle.fireLifecycleEvent(STOPPED_EVENT, this); + } + + private void loadAll() { + CryptoModuleConfigLoader loader = ctx().getBean(CryptoModuleConfigLoader.class); + CryptoModuleConfigMapper mapper = ctx().getBean(CryptoModuleConfigMapper.class); + + List all = loader.findAll(); + configMap.clear(); + for (CryptoModuleConfig entity : all) { + if ("Y".equalsIgnoreCase(entity.getUseYn())) { + CryptoModuleConfigVO vo = mapper.toVo(entity); + configMap.put(vo.getCryptoName(), vo); + } + } + logger.warn("CryptoModuleManager] 암호화모듈 로드 완료. 건수=" + configMap.size()); + } + + public void reload(String cryptoId) { + CryptoModuleConfigLoader loader = ctx().getBean(CryptoModuleConfigLoader.class); + CryptoModuleConfigMapper mapper = ctx().getBean(CryptoModuleConfigMapper.class); + + configMap.values().removeIf(vo -> cryptoId.equals(vo.getCryptoId())); + evictDynamicCache(cryptoId); + + loader.findById(cryptoId).ifPresent(entity -> { + if ("Y".equalsIgnoreCase(entity.getUseYn())) { + CryptoModuleConfigVO vo = mapper.toVo(entity); + configMap.put(vo.getCryptoName(), vo); + logger.warn("CryptoModuleManager] 재로드 완료: " + vo.getCryptoName()); + } + }); + } + + /** + * STATIC 키 방식 — 초기화된 CryptoModuleExtension 반환. + * Cipher는 thread-safe하지 않으므로 호출마다 새 인스턴스를 생성한다. + */ + public CryptoModuleExtension createExtension(String cryptoName) throws Exception { + CryptoModuleConfigVO vo = getVO(cryptoName); + byte[] encKey = DatatypeConverter.parseHexBinary(vo.getEncKeyHex()); + byte[] decKey = vo.getDecKeyHex() != null + ? DatatypeConverter.parseHexBinary(vo.getDecKeyHex()) + : encKey; + byte[] iv = vo.getIvHex() != null ? DatatypeConverter.parseHexBinary(vo.getIvHex()) : null; + return buildExtension(vo, iv, encKey, decKey); + } + + /** + * DYNAMIC 키 방식 — 전략으로 키를 도출하고 캐시를 적용한 뒤 CryptoModuleExtension 반환. + */ + public CryptoModuleExtension createExtension(String cryptoName, Map runtimeContext) + throws Exception { + CryptoModuleConfigVO vo = getVO(cryptoName); + + if ("STATIC".equalsIgnoreCase(vo.getKeySourceType())) { + return createExtension(cryptoName); + } + + KeyDerivationStrategy strategy = resolveStrategy(vo.getKeyDerivStrategy()); + Map params = parseParams(vo.getKeyDerivParams()); + String cacheKey = strategy.buildCacheKey(cryptoName, params, runtimeContext); + + DerivedKey derivedKey = null; + + if ("Y".equalsIgnoreCase(vo.getCacheYn())) { + CachedDerivedKey cached = dynamicKeyCache.get(cacheKey); + if (cached != null && !cached.isExpired()) { + derivedKey = cached.getDerivedKey(); + } + } + + if (derivedKey == null) { + derivedKey = strategy.deriveKey(params, runtimeContext); + if ("Y".equalsIgnoreCase(vo.getCacheYn())) { + int ttl = vo.getCacheTtlSec() != null ? vo.getCacheTtlSec() : 300; + dynamicKeyCache.put(cacheKey, new CachedDerivedKey(derivedKey, ttl)); + } + } + + byte[] iv = vo.getIvHex() != null ? DatatypeConverter.parseHexBinary(vo.getIvHex()) : null; + return buildExtension(vo, iv, derivedKey.getEncKey(), derivedKey.getDecKey()); + } + + private CryptoModuleConfigVO getVO(String cryptoName) { + CryptoModuleConfigVO vo = configMap.get(cryptoName); + if (vo == null) { + throw new IllegalArgumentException("암호화모듈 미등록: " + cryptoName); + } + return vo; + } + + private CryptoModuleExtension buildExtension(CryptoModuleConfigVO vo, byte[] iv, + byte[] encKey, byte[] decKey) throws Exception { + CryptoModuleExtension ext = "ARIA".equalsIgnoreCase(vo.getAlgType()) + ? new ARIACryptoModuleExtension() + : new AESCryptoModuleExtension(); + ext.init(vo.getAlgType(), vo.getCipherMode(), vo.getPadding(), iv, encKey, decKey); + return ext; + } + + /** + * DB key_deriv_strategy 컬럼에 저장된 FQCN으로 전략 클래스를 로드한다. + * 한 번 로드된 인스턴스는 strategyCache에 보관하여 재사용한다. + */ + private KeyDerivationStrategy resolveStrategy(String fqcn) { + return strategyCache.computeIfAbsent(fqcn, key -> { + try { + Class clazz = Class.forName(key); + return (KeyDerivationStrategy) clazz.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new IllegalArgumentException("전략 클래스 로드 실패: " + key, e); + } + }); + } + + private Map parseParams(String json) throws Exception { + if (json == null || json.trim().isEmpty()) { + return new HashMap<>(); + } + return objectMapper.readValue(json, new TypeReference>() {}); + } + + private void evictDynamicCache(String cryptoId) { + configMap.values().stream() + .filter(vo -> cryptoId.equals(vo.getCryptoId())) + .map(CryptoModuleConfigVO::getCryptoName) + .forEach(name -> { + Iterator it = dynamicKeyCache.keySet().iterator(); + while (it.hasNext()) { + if (it.next().startsWith(name + ":")) it.remove(); + } + }); + } + + private org.springframework.context.ApplicationContext ctx() { + return ApplicationContextProvider.getContext(); + } + + @Override + public void addLifecycleListener(LifecycleListener listener) { + lifecycle.addLifecycleListener(listener); + } + + @Override + public LifecycleListener[] findLifecycleListeners() { + return lifecycle.findLifecycleListeners(); + } + + @Override + public void removeLifecycleListener(LifecycleListener listener) { + lifecycle.removeLifecycleListener(listener); + } + + @Override + public boolean isStarted() { + return started; + } + + private static class CachedDerivedKey { + private final DerivedKey derivedKey; + private final long expireAt; + + CachedDerivedKey(DerivedKey derivedKey, int ttlSec) { + this.derivedKey = derivedKey; + this.expireAt = System.currentTimeMillis() + (ttlSec * 1000L); + } + + boolean isExpired() { + return System.currentTimeMillis() > expireAt; + } + + DerivedKey getDerivedKey() { + return derivedKey; + } + } +} diff --git a/src/main/java/com/eactive/eai/common/security/CryptoModuleService.java b/src/main/java/com/eactive/eai/common/security/CryptoModuleService.java new file mode 100644 index 0000000..941d191 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/CryptoModuleService.java @@ -0,0 +1,77 @@ +package com.eactive.eai.common.security; + +import java.util.Map; + +import org.springframework.stereotype.Service; + +import com.eactive.eai.common.util.ApplicationContextProvider; + +/** + * 어댑터 필터 등에서 암복호화를 위임하는 진입점. + * + * STATIC 키 방식: + * service.encrypt("MODULE_NAME", plainBytes); + * + * DYNAMIC 키 방식 (런타임 컨텍스트 전달): + * Map ctx = Map.of("X-Api-Enc-Key", request.getHeader("X-Api-Enc-Key")); + * service.encrypt("MODULE_NAME", ctx, plainBytes); + * + * AAD 명시 방식 (GCM 인증 데이터 전달): + * byte[] aad = request.getHeader("X-Api-Request-Id").getBytes(StandardCharsets.UTF_8); + * service.encrypt("MODULE_NAME", ctx, aad, plainBytes); + */ +@Service +public class CryptoModuleService { + + public static CryptoModuleService getInstance() { + return ApplicationContextProvider.getContext().getBean(CryptoModuleService.class); + } + + // ------------------------------------------------------------------ + // STATIC 키 + // ------------------------------------------------------------------ + + public byte[] encrypt(String cryptoName, byte[] plainBytes) throws Exception { + CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName); + return ext.encrypt(plainBytes, 0, plainBytes.length); + } + + public byte[] encrypt(String cryptoName, byte[] aad, byte[] plainBytes) throws Exception { + CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName); + return ext.encrypt(plainBytes, 0, plainBytes.length, aad); + } + + public byte[] decrypt(String cryptoName, byte[] cipherBytes) throws Exception { + CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName); + return ext.decrypt(cipherBytes, 0, cipherBytes.length); + } + + public byte[] decrypt(String cryptoName, byte[] aad, byte[] cipherBytes) throws Exception { + CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName); + return ext.decrypt(cipherBytes, 0, cipherBytes.length, aad); + } + + // ------------------------------------------------------------------ + // DYNAMIC 키 + // ------------------------------------------------------------------ + + public byte[] encrypt(String cryptoName, Map runtimeContext, byte[] plainBytes) throws Exception { + CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName, runtimeContext); + return ext.encrypt(plainBytes, 0, plainBytes.length); + } + + public byte[] encrypt(String cryptoName, Map runtimeContext, byte[] aad, byte[] plainBytes) throws Exception { + CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName, runtimeContext); + return ext.encrypt(plainBytes, 0, plainBytes.length, aad); + } + + public byte[] decrypt(String cryptoName, Map runtimeContext, byte[] cipherBytes) throws Exception { + CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName, runtimeContext); + return ext.decrypt(cipherBytes, 0, cipherBytes.length); + } + + public byte[] decrypt(String cryptoName, Map runtimeContext, byte[] aad, byte[] cipherBytes) throws Exception { + CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName, runtimeContext); + return ext.decrypt(cipherBytes, 0, cipherBytes.length, aad); + } +} diff --git a/src/main/java/com/eactive/eai/common/security/keyderiv/DerivedKey.java b/src/main/java/com/eactive/eai/common/security/keyderiv/DerivedKey.java new file mode 100644 index 0000000..93e22b4 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/keyderiv/DerivedKey.java @@ -0,0 +1,20 @@ +package com.eactive.eai.common.security.keyderiv; + +public class DerivedKey { + + private final byte[] encKey; + private final byte[] decKey; + + public DerivedKey(byte[] encKey, byte[] decKey) { + this.encKey = encKey; + this.decKey = decKey != null ? decKey : encKey; + } + + public byte[] getEncKey() { + return encKey; + } + + public byte[] getDecKey() { + return decKey; + } +} diff --git a/src/main/java/com/eactive/eai/common/security/keyderiv/KeyDerivationStrategy.java b/src/main/java/com/eactive/eai/common/security/keyderiv/KeyDerivationStrategy.java new file mode 100644 index 0000000..691b98f --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/keyderiv/KeyDerivationStrategy.java @@ -0,0 +1,23 @@ +package com.eactive.eai.common.security.keyderiv; + +import java.util.Map; + +public interface KeyDerivationStrategy { + + /** + * 키를 도출한다. + * + * @param params DB의 key_deriv_params (JSON 파싱된 Map) + * @param runtimeContext 런타임 컨텍스트 (HTTP 헤더, 거래 정보 등 호출자가 채워서 전달) + */ + DerivedKey deriveKey(Map params, Map runtimeContext) throws Exception; + + /** + * 캐시 키를 생성한다. 전략마다 어떤 runtimeContext 값이 키를 결정하는지 다르다. + * + * @param cryptoName 암호화모듈 이름 + * @param params DB의 key_deriv_params + * @param runtimeContext 런타임 컨텍스트 + */ + String buildCacheKey(String cryptoName, Map params, Map runtimeContext); +} diff --git a/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmContextXorKeyDerivationStrategy.java b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmContextXorKeyDerivationStrategy.java new file mode 100644 index 0000000..550316b --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/keyderiv/strategy/HsmContextXorKeyDerivationStrategy.java @@ -0,0 +1,80 @@ +package com.eactive.eai.common.security.keyderiv.strategy; + +import java.util.Arrays; +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 마스터키 + 런타임 컨텍스트값 XOR 조합으로 키를 도출하는 전략. + * + * key_deriv_params (JSON) 예시: + * { + * "hsmKeyAlias" : "MASTER_KEY_AES", + * "contextKey" : "X-Api-Enc-Key", -- runtimeContext에서 꺼낼 키 이름 + * "offset" : "0", -- contextKey 값 중 사용할 시작 위치 (byte) + * "length" : "16" -- contextKey 값 중 사용할 길이 (byte) + * } + */ +public class HsmContextXorKeyDerivationStrategy implements KeyDerivationStrategy { + + /** DB key_deriv_strategy 컬럼에 사용하는 FQCN 참조용 상수. */ + public static final String STRATEGY_CLASS = HsmContextXorKeyDerivationStrategy.class.getName(); + + private static final String PARAM_HSM_KEY_ALIAS = "hsmKeyAlias"; + private static final String PARAM_CONTEXT_KEY = "contextKey"; + private static final String PARAM_OFFSET = "offset"; + private static final String PARAM_LENGTH = "length"; + + @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); + int offset = Integer.parseInt(params.getOrDefault(PARAM_OFFSET, "0")); + int length = Integer.parseInt(required(params, PARAM_LENGTH)); + + SecretKey masterKey = hsmCryptoService().getSecretKey(hsmKeyAlias); + byte[] masterBytes = masterKey.getEncoded(); + + String contextValue = runtimeContext.getOrDefault(contextKey, ""); + byte[] contextBytes = contextValue.getBytes("UTF-8"); + + byte[] derived = xor(masterBytes, contextBytes, offset, length); + 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 byte[] xor(byte[] masterBytes, byte[] contextBytes, int offset, int length) { + byte[] derived = Arrays.copyOf(masterBytes, masterBytes.length); + for (int i = 0; i < length && i < derived.length; i++) { + int contextIdx = offset + i; + if (contextIdx < contextBytes.length) { + derived[i] ^= contextBytes[contextIdx]; + } + } + return derived; + } + + 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/mapper/CryptoModuleConfigMapper.java b/src/main/java/com/eactive/eai/common/security/mapper/CryptoModuleConfigMapper.java new file mode 100644 index 0000000..21b4ecc --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/mapper/CryptoModuleConfigMapper.java @@ -0,0 +1,19 @@ +package com.eactive.eai.common.security.mapper; + +import org.mapstruct.InheritInverseConfiguration; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +import com.eactive.eai.common.security.CryptoModuleConfigVO; +import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig; +import com.eactive.eai.data.mapper.GenericMapper; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface CryptoModuleConfigMapper extends GenericMapper { + + @Override + CryptoModuleConfigVO toVo(CryptoModuleConfig entity); + + @InheritInverseConfiguration + CryptoModuleConfig toEntity(CryptoModuleConfigVO vo); +} diff --git a/src/test/java/com/eactive/eai/common/security/AESCryptoModuleExtensionTest.java b/src/test/java/com/eactive/eai/common/security/AESCryptoModuleExtensionTest.java new file mode 100644 index 0000000..98361f8 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/security/AESCryptoModuleExtensionTest.java @@ -0,0 +1,399 @@ +package com.eactive.eai.common.security; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +/** + * AESCryptoModuleExtension 단위 테스트 + * Spring 컨텍스트 없이 순수 암복호화 로직만 검증한다. + */ +@TestMethodOrder(MethodOrderer.DisplayName.class) +class AESCryptoModuleExtensionTest { + + private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8); + private static final byte[] KEY_256 = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8); + private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8); + private static final byte[] PLAIN = "암호화 테스트 평문 데이터입니다.".getBytes(StandardCharsets.UTF_8); + + // ========================================================================= + // 1. CBC 모드 + // ========================================================================= + + @Test + @DisplayName("1-1. AES/CBC/PKCS5Padding 128bit — 암복호화 라운드트립") + void testCbc128_roundTrip() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length); + + assertNotNull(encrypted); + assertFalse(Arrays.equals(PLAIN, encrypted), "암호문은 평문과 달라야 한다"); + assertArrayEquals(PLAIN, decrypted, "복호화 결과가 원문과 일치해야 한다"); + } + + @Test + @DisplayName("1-2. AES/CBC/PKCS5Padding 256bit — 암복호화 라운드트립") + void testCbc256_roundTrip() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_256, KEY_256); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length); + + assertArrayEquals(PLAIN, decrypted); + } + + @Test + @DisplayName("1-3. AES/CBC — 암호화 결과는 블록 크기(16바이트)의 배수") + void testCbc_ciphertextIsMultipleOfBlockSize() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + + assertEquals(0, encrypted.length % 16, "CBC 암호문 길이는 16 바이트 배수여야 한다"); + } + + @Test + @DisplayName("1-4. AES/CBC — 잘못된 키로 복호화 시 예외 발생") + void testCbc_wrongKeyThrowsException() throws Exception { + AESCryptoModuleExtension encExt = new AESCryptoModuleExtension(); + encExt.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + byte[] encrypted = encExt.encrypt(PLAIN, 0, PLAIN.length); + + byte[] wrongKey = "wrongkey12345678".getBytes(StandardCharsets.UTF_8); + AESCryptoModuleExtension decExt = new AESCryptoModuleExtension(); + decExt.init("AES", "CBC", "PKCS5Padding", IV_16, wrongKey, wrongKey); + + assertThrows(Exception.class, () -> decExt.decrypt(encrypted, 0, encrypted.length), + "잘못된 키로 복호화 시 예외가 발생해야 한다"); + } + + // ========================================================================= + // 2. GCM 모드 + // ========================================================================= + + @Test + @DisplayName("2-1. AES/GCM/NoPadding 128bit — 암복호화 라운드트립") + void testGcm128_roundTrip() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length); + + assertNotNull(encrypted); + assertArrayEquals(PLAIN, decrypted, "GCM 복호화 결과가 원문과 일치해야 한다"); + } + + @Test + @DisplayName("2-2. AES/GCM — 암호화마다 랜덤 Nonce → 동일 평문도 다른 암호문 생성") + void testGcm_randomNonce_differentCiphertextEachCall() throws Exception { + AESCryptoModuleExtension ext1 = new AESCryptoModuleExtension(); + ext1.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + AESCryptoModuleExtension ext2 = new AESCryptoModuleExtension(); + ext2.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + byte[] enc1 = ext1.encrypt(PLAIN, 0, PLAIN.length); + byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length); + + assertFalse(Arrays.equals(enc1, enc2), "GCM은 랜덤 Nonce로 매번 다른 암호문을 생성해야 한다"); + } + + @Test + @DisplayName("2-3. AES/GCM — 암호문 앞 12바이트가 Nonce") + void testGcm_ciphertextStartsWithNonce() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + + assertTrue(encrypted.length > 12, "GCM 암호문은 Nonce(12바이트) + 암호문 + Tag(16바이트) 구조여야 한다"); + } + + @Test + @DisplayName("2-4. AES/GCM — 256bit 키 라운드트립") + void testGcm256_roundTrip() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "GCM", "NoPadding", IV_16, KEY_256, KEY_256); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length); + + assertArrayEquals(PLAIN, decrypted); + } + + // ========================================================================= + // 3. init 편의 메서드 (IV 없는 버전) + // ========================================================================= + + @Test + @DisplayName("3-1. init(alg, mode, pad, encKey, decKey) — IV null로 CBC 초기화 시 예외") + void testInitWithoutIv_cbcThrowsException() { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + assertThrows(Exception.class, + () -> ext.init("AES", "CBC", "PKCS5Padding", KEY_128, KEY_128), + "IV 없이 CBC 초기화 시 예외가 발생해야 한다"); + } + + // ========================================================================= + // 4. 바이트 배열 오프셋/길이 encrypt/decrypt + // ========================================================================= + + @Test + @DisplayName("4-1. encrypt(src, sindex, slength, dst, dindex) — 버퍼 직접 지정 라운드트립") + void testEncryptDecryptWithBuffer() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] dst = new byte[256]; + int encLen = ext.encrypt(PLAIN, 0, PLAIN.length, dst, 0); + + byte[] decDst = new byte[256]; + int decLen = ext.decrypt(dst, 0, encLen, decDst, 0); + + assertArrayEquals(PLAIN, Arrays.copyOf(decDst, decLen)); + } + + // ========================================================================= + // 5. 프로바이더 파라미터 + // ========================================================================= + + @Test + @DisplayName("5-1. provider=BC 명시 — CBC 암복호화 라운드트립") + void testProvider_bc_cbcRoundTrip() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128, "BC"); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length); + + assertArrayEquals(PLAIN, decrypted, "BC 프로바이더 명시 시 CBC 암복호화가 성공해야 한다"); + } + + @Test + @DisplayName("5-2. provider=BC 명시 — GCM 암복호화 라운드트립") + void testProvider_bc_gcmRoundTrip() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128, "BC"); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length); + + assertArrayEquals(PLAIN, decrypted, "BC 프로바이더 명시 시 GCM 암복호화가 성공해야 한다"); + } + + @Test + @DisplayName("5-3. provider null — GCM 기본 동작(BC) 라운드트립") + void testProvider_null_gcmDefaultBcRoundTrip() throws Exception { + AESCryptoModuleExtension enc = new AESCryptoModuleExtension(); + enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128, (String) null); + + AESCryptoModuleExtension dec = new AESCryptoModuleExtension(); + dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128, (String) null); + + byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length); + byte[] decrypted = dec.decrypt(encrypted, 0, encrypted.length); + + assertArrayEquals(PLAIN, decrypted, "provider=null이면 GCM은 BC 기본값으로 동작해야 한다"); + } + + @Test + @DisplayName("5-4. provider 파라미터 없는 CBC init — provider 없는 버전과 동일 결과") + void testProvider_cbcWithAndWithoutProvider_sameResult() throws Exception { + AESCryptoModuleExtension ext1 = new AESCryptoModuleExtension(); + ext1.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + AESCryptoModuleExtension ext2 = new AESCryptoModuleExtension(); + ext2.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128, "BC"); + + byte[] enc1 = ext1.encrypt(PLAIN, 0, PLAIN.length); + byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length); + + assertArrayEquals(enc1, enc2, "동일 키/IV에서 provider 유무와 무관하게 CBC 암호문이 같아야 한다"); + } + + // ========================================================================= + // 6. AAD (Additional Authenticated Data) + // ========================================================================= + + @Test + @DisplayName("6-1. GCM — 명시적 AAD로 암복호화 라운드트립") + void testGcm_explicitAad_roundTrip() throws Exception { + byte[] aad = "header-context-data".getBytes(StandardCharsets.UTF_8); + + AESCryptoModuleExtension enc = new AESCryptoModuleExtension(); + enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + AESCryptoModuleExtension dec = new AESCryptoModuleExtension(); + dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length, aad); + byte[] decrypted = dec.decrypt(encrypted, 0, encrypted.length, aad); + + assertArrayEquals(PLAIN, decrypted, "명시적 AAD로 암복호화 라운드트립이 성공해야 한다"); + } + + @Test + @DisplayName("6-2. GCM — AAD null이면 IV를 AAD로 사용하는 기본 동작") + void testGcm_nullAad_defaultIvAsAad() throws Exception { + AESCryptoModuleExtension enc = new AESCryptoModuleExtension(); + enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + AESCryptoModuleExtension dec = new AESCryptoModuleExtension(); + dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + // aad=null → IV_16을 AAD로 사용 (기본 동작) + byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length, null); + byte[] decrypted = dec.decrypt(encrypted, 0, encrypted.length, null); + + assertArrayEquals(PLAIN, decrypted, "AAD=null이면 IV를 AAD로 사용하는 기본 동작이어야 한다"); + } + + @Test + @DisplayName("6-3. GCM — encrypt(aad=null)과 encrypt() 결과가 복호화 상호 호환") + void testGcm_nullAadCompatibleWithNoAadMethod() throws Exception { + AESCryptoModuleExtension enc = new AESCryptoModuleExtension(); + enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + AESCryptoModuleExtension dec = new AESCryptoModuleExtension(); + dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + byte[] encryptedByNoArg = enc.encrypt(PLAIN, 0, PLAIN.length); + byte[] decryptedByNullAad = dec.decrypt(encryptedByNoArg, 0, encryptedByNoArg.length, null); + + assertArrayEquals(PLAIN, decryptedByNullAad, "encrypt()와 decrypt(null)은 상호 호환되어야 한다"); + } + + @Test + @DisplayName("6-4. GCM — 다른 AAD로 복호화 시 인증 실패 예외") + void testGcm_wrongAad_throwsAuthException() throws Exception { + byte[] aad = "correct-aad-value".getBytes(StandardCharsets.UTF_8); + byte[] wrongAad = "wrong-aad-value!!".getBytes(StandardCharsets.UTF_8); + + AESCryptoModuleExtension enc = new AESCryptoModuleExtension(); + enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length, aad); + + AESCryptoModuleExtension dec = new AESCryptoModuleExtension(); + dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + assertThrows(Exception.class, + () -> dec.decrypt(encrypted, 0, encrypted.length, wrongAad), + "잘못된 AAD로 GCM 복호화 시 인증 실패 예외가 발생해야 한다"); + } + + @Test + @DisplayName("6-5. GCM — AAD 있는 암호문을 AAD 없이(null IV) 복호화 시 인증 실패") + void testGcm_encryptedWithAad_decryptWithoutAad_fails() throws Exception { + byte[] aad = "must-have-aad".getBytes(StandardCharsets.UTF_8); + + AESCryptoModuleExtension enc = new AESCryptoModuleExtension(); + enc.init("AES", "GCM", "NoPadding", null, KEY_128, KEY_128); + byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length, aad); + + AESCryptoModuleExtension dec = new AESCryptoModuleExtension(); + dec.init("AES", "GCM", "NoPadding", null, KEY_128, KEY_128); + + assertThrows(Exception.class, + () -> dec.decrypt(encrypted, 0, encrypted.length, null), + "AAD로 암호화된 데이터를 AAD 없이 복호화하면 예외가 발생해야 한다"); + } + + @Test + @DisplayName("6-6. CBC — AAD 파라미터는 무시되고 정상 동작") + void testCbc_aadIgnored_normalOperation() throws Exception { + byte[] aad = "ignored-in-cbc".getBytes(StandardCharsets.UTF_8); + + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length, aad); + byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length, aad); + + assertArrayEquals(PLAIN, decrypted, "CBC 모드에서 AAD는 무시되고 정상 암복호화되어야 한다"); + } + + // ========================================================================= + // 7. 오프셋/길이 없는 편의 메서드 + // ========================================================================= + + @Test + @DisplayName("7-1. CBC — encrypt(byte[]) / decrypt(byte[]) 라운드트립") + void testCbc_noOffsetEncryptDecrypt_roundTrip() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN); + byte[] decrypted = ext.decrypt(encrypted); + + assertArrayEquals(PLAIN, decrypted); + } + + @Test + @DisplayName("7-2. GCM — encrypt(byte[]) / decrypt(byte[]) 라운드트립") + void testGcm_noOffsetEncryptDecrypt_roundTrip() throws Exception { + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN); + byte[] decrypted = ext.decrypt(encrypted); + + assertArrayEquals(PLAIN, decrypted); + } + + @Test + @DisplayName("7-3. GCM — encrypt(byte[], aad) / decrypt(byte[], aad) 라운드트립") + void testGcm_noOffsetWithAad_roundTrip() throws Exception { + byte[] aad = "request-header-ctx".getBytes(StandardCharsets.UTF_8); + + AESCryptoModuleExtension enc = new AESCryptoModuleExtension(); + enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + AESCryptoModuleExtension dec = new AESCryptoModuleExtension(); + dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = enc.encrypt(PLAIN, aad); + byte[] decrypted = dec.decrypt(encrypted, aad); + + assertArrayEquals(PLAIN, decrypted); + } + + @Test + @DisplayName("7-4. CBC — encrypt(byte[], aad) 는 AAD 무시 후 정상 라운드트립") + void testCbc_noOffsetWithAad_aadIgnored() throws Exception { + byte[] aad = "ignored".getBytes(StandardCharsets.UTF_8); + + AESCryptoModuleExtension ext = new AESCryptoModuleExtension(); + ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN, aad); + byte[] decrypted = ext.decrypt(encrypted, aad); + + assertArrayEquals(PLAIN, decrypted); + } + + @Test + @DisplayName("7-5. encrypt(byte[])와 encrypt(src, 0, src.length) 결과 동일 — CBC") + void testCbc_noOffsetEquivalentToFullOffset() throws Exception { + AESCryptoModuleExtension ext1 = new AESCryptoModuleExtension(); + ext1.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + AESCryptoModuleExtension ext2 = new AESCryptoModuleExtension(); + ext2.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] enc1 = ext1.encrypt(PLAIN); + byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length); + + assertArrayEquals(enc1, enc2, "편의 메서드와 오프셋 메서드의 결과가 동일해야 한다"); + } +} diff --git a/src/test/java/com/eactive/eai/common/security/ARIACryptoModuleExtensionTest.java b/src/test/java/com/eactive/eai/common/security/ARIACryptoModuleExtensionTest.java new file mode 100644 index 0000000..f3cbd14 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/security/ARIACryptoModuleExtensionTest.java @@ -0,0 +1,154 @@ +package com.eactive.eai.common.security; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +/** + * ARIACryptoModuleExtension 단위 테스트 + * BouncyCastle ARIA 암복호화 로직을 검증한다. + */ +@TestMethodOrder(MethodOrderer.DisplayName.class) +class ARIACryptoModuleExtensionTest { + + private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8); + private static final byte[] KEY_256 = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8); + private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8); + private static final byte[] PLAIN = "ARIA 암호화 테스트 평문입니다.".getBytes(StandardCharsets.UTF_8); + + // ========================================================================= + // 1. CBC 모드 + // ========================================================================= + + @Test + @DisplayName("1-1. ARIA/CBC/PKCS5Padding 128bit — 암복호화 라운드트립") + void testCbc128_roundTrip() throws Exception { + ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension(); + ext.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length); + + assertNotNull(encrypted); + assertFalse(Arrays.equals(PLAIN, encrypted), "암호문은 평문과 달라야 한다"); + assertArrayEquals(PLAIN, decrypted, "복호화 결과가 원문과 일치해야 한다"); + } + + @Test + @DisplayName("1-2. ARIA/CBC/PKCS5Padding 256bit — 암복호화 라운드트립") + void testCbc256_roundTrip() throws Exception { + ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension(); + ext.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_256, KEY_256); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length); + + assertArrayEquals(PLAIN, decrypted); + } + + @Test + @DisplayName("1-3. ARIA/CBC — 암호화 결과는 블록 크기(16바이트)의 배수") + void testCbc_ciphertextIsMultipleOfBlockSize() throws Exception { + ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension(); + ext.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length); + + assertEquals(0, encrypted.length % 16, "ARIA CBC 암호문 길이는 16 바이트 배수여야 한다"); + } + + @Test + @DisplayName("1-4. ARIA/CBC — 다른 IV로 암호화 시 다른 암호문 생성") + void testCbc_differentIv_differentCiphertext() throws Exception { + byte[] iv2 = "9876543210fedcba".getBytes(StandardCharsets.UTF_8); + + ARIACryptoModuleExtension ext1 = new ARIACryptoModuleExtension(); + ext1.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + ARIACryptoModuleExtension ext2 = new ARIACryptoModuleExtension(); + ext2.init("ARIA", "CBC", "PKCS5Padding", iv2, KEY_128, KEY_128); + + byte[] enc1 = ext1.encrypt(PLAIN, 0, PLAIN.length); + byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length); + + assertFalse(Arrays.equals(enc1, enc2), "IV가 다르면 암호문도 달라야 한다"); + } + + @Test + @DisplayName("1-5. ARIA/CBC — 잘못된 키로 복호화 시 예외 발생") + void testCbc_wrongKeyThrowsException() throws Exception { + ARIACryptoModuleExtension encExt = new ARIACryptoModuleExtension(); + encExt.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + byte[] encrypted = encExt.encrypt(PLAIN, 0, PLAIN.length); + + byte[] wrongKey = "wrongkey12345678".getBytes(StandardCharsets.UTF_8); + ARIACryptoModuleExtension decExt = new ARIACryptoModuleExtension(); + decExt.init("ARIA", "CBC", "PKCS5Padding", IV_16, wrongKey, wrongKey); + + assertThrows(Exception.class, () -> decExt.decrypt(encrypted, 0, encrypted.length), + "잘못된 키로 복호화 시 예외가 발생해야 한다"); + } + + // ========================================================================= + // 2. 암/복호화 키 분리 + // ========================================================================= + + @Test + @DisplayName("2-1. init(alg, mode, pad, encKey, decKey) — IV null 편의 메서드") + void testInitWithoutIv() throws Exception { + ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension(); + // IV 없이 초기화 (null → IvParameterSpec도 null → CBC에서 예외) + assertThrows(Exception.class, + () -> ext.init("ARIA", "CBC", "PKCS5Padding", KEY_128, KEY_128), + "IV 없이 CBC 초기화 시 예외가 발생해야 한다"); + } + + // ========================================================================= + // 3. 바이트 배열 오프셋/길이 encrypt/decrypt + // ========================================================================= + + @Test + @DisplayName("3-1. encrypt(src, sindex, slength, dst, dindex) — 버퍼 직접 지정 라운드트립") + void testEncryptDecryptWithBuffer() throws Exception { + ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension(); + ext.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] dst = new byte[256]; + int encLen = ext.encrypt(PLAIN, 0, PLAIN.length, dst, 0); + + byte[] decDst = new byte[256]; + int decLen = ext.decrypt(dst, 0, encLen, decDst, 0); + + assertArrayEquals(PLAIN, Arrays.copyOf(decDst, decLen)); + } + + // ========================================================================= + // 4. BouncyCastle Provider 자동 등록 + // ========================================================================= + + @Test + @DisplayName("4-1. 동일 평문을 여러 번 암호화 — 각각 새 인스턴스로 독립 처리") + void testMultipleInstances_independentEncryption() throws Exception { + ARIACryptoModuleExtension ext1 = new ARIACryptoModuleExtension(); + ext1.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + ARIACryptoModuleExtension ext2 = new ARIACryptoModuleExtension(); + ext2.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128); + + byte[] enc1 = ext1.encrypt(PLAIN, 0, PLAIN.length); + byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length); + + // 같은 키/IV → 같은 암호문 + assertArrayEquals(enc1, enc2, "동일 키/IV라면 암호문이 동일해야 한다"); + + // 각자 복호화 + assertArrayEquals(PLAIN, ext1.decrypt(enc1, 0, enc1.length)); + assertArrayEquals(PLAIN, ext2.decrypt(enc2, 0, enc2.length)); + } +} diff --git a/src/test/java/com/eactive/eai/common/security/CryptoModuleManagerTest.java b/src/test/java/com/eactive/eai/common/security/CryptoModuleManagerTest.java new file mode 100644 index 0000000..ec6e23c --- /dev/null +++ b/src/test/java/com/eactive/eai/common/security/CryptoModuleManagerTest.java @@ -0,0 +1,391 @@ +package com.eactive.eai.common.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.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.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; + +/** + * CryptoModuleManager 단위 테스트 + * DB / 실 Spring 컨텍스트 없이 Mock으로 동작한다. + */ +@TestMethodOrder(MethodOrderer.DisplayName.class) +class CryptoModuleManagerTest { + + private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8); + private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8); + private static final String ENC_KEY_HEX = toHex(KEY_128); + private static final String IV_HEX = toHex(IV_16); + + private static GenericApplicationContext ctx; + private static CryptoModuleConfigLoader mockLoader; + private static HsmCryptoService mockHsmCryptoService; + private static CryptoModuleManager manager; + + @BeforeAll + static void setUpClass() throws Exception { + mockLoader = mock(CryptoModuleConfigLoader.class); + mockHsmCryptoService = mock(HsmCryptoService.class); + + SecretKey masterKey = new SecretKeySpec(KEY_128, "AES"); + when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey); + when(mockLoader.findAll()).thenReturn(Collections.emptyList()); + + Constructor ctor = CryptoModuleManager.class.getDeclaredConstructor(); + ctor.setAccessible(true); + manager = ctor.newInstance(); + + ctx = new GenericApplicationContext(); + ctx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader); + ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsmCryptoService); + ctx.getBeanFactory().registerSingleton("cryptoModuleManager", manager); + ctx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper()); + ctx.registerBeanDefinition("applicationContextProvider", + BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class) + .getBeanDefinition()); + ctx.refresh(); + + manager.start(); + } + + @AfterAll + static void tearDownClass() { + if (ctx != null) ctx.close(); + } + + @BeforeEach + void resetMocks() { + reset(mockLoader); + when(mockLoader.findAll()).thenReturn(Collections.emptyList()); + } + + // ========================================================================= + // 1. start / stop Lifecycle + // ========================================================================= + + @Test + @DisplayName("1-1. start() 후 isStarted() = true") + void testIsStarted() { + assertTrue(manager.isStarted()); + } + + // ========================================================================= + // 2. STATIC 키 — createExtension + // ========================================================================= + + @Test + @DisplayName("2-1. STATIC AES/CBC — createExtension 반환 및 암복호화 동작") + void testCreateExtension_staticAesCbc_roundTrip() throws Exception { + CryptoModuleConfig config = buildStaticConfig("AES_CBC", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + byte[] plain = "테스트 평문".getBytes(StandardCharsets.UTF_8); + CryptoModuleExtension ext = manager.createExtension("AES_CBC"); + byte[] encrypted = ext.encrypt(plain, 0, plain.length); + + CryptoModuleExtension extDec = manager.createExtension("AES_CBC"); + byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length); + + assertArrayEquals(plain, decrypted, "STATIC AES/CBC 암복호화 라운드트립 성공해야 한다"); + } + + @Test + @DisplayName("2-2. STATIC ARIA/CBC — createExtension 반환 및 암복호화 동작") + void testCreateExtension_staticAriaCbc_roundTrip() throws Exception { + CryptoModuleConfig config = buildStaticConfig("ARIA_CBC", "ARIA", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + byte[] plain = "ARIA 테스트".getBytes(StandardCharsets.UTF_8); + CryptoModuleExtension ext = manager.createExtension("ARIA_CBC"); + byte[] encrypted = ext.encrypt(plain, 0, plain.length); + + CryptoModuleExtension extDec = manager.createExtension("ARIA_CBC"); + byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length); + + assertArrayEquals(plain, decrypted); + } + + @Test + @DisplayName("2-3. 미등록 cryptoName — IllegalArgumentException") + void testCreateExtension_unknownName_throwsException() { + assertThrows(IllegalArgumentException.class, + () -> manager.createExtension("UNKNOWN_MODULE"), + "미등록 cryptoName은 IllegalArgumentException이어야 한다"); + } + + @Test + @DisplayName("2-4. use_yn=N 설정 — configMap 미등록") + void testLoadAll_disabledConfig_notRegistered() throws Exception { + CryptoModuleConfig config = buildStaticConfig("DISABLED_MOD", "AES", "CBC", "PKCS5Padding"); + config.setUseYn("N"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + assertThrows(IllegalArgumentException.class, + () -> manager.createExtension("DISABLED_MOD"), + "use_yn=N 설정은 로드되지 않아야 한다"); + } + + // ========================================================================= + // 3. DYNAMIC 키 — createExtension with runtimeContext + // ========================================================================= + + @Test + @DisplayName("3-1. DYNAMIC AES/CBC — runtimeContext 전달 시 암복호화 동작") + void testCreateExtension_dynamicAesCbc_roundTrip() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("DYN_AES_CBC", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + Map ctx = new HashMap<>(); + ctx.put("X-Api-Enc-Key", "clientKeyValue01"); + + byte[] plain = "DYNAMIC 키 테스트".getBytes(StandardCharsets.UTF_8); + CryptoModuleExtension ext = manager.createExtension("DYN_AES_CBC", ctx); + byte[] encrypted = ext.encrypt(plain, 0, plain.length); + + CryptoModuleExtension extDec = manager.createExtension("DYN_AES_CBC", ctx); + byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length); + + assertArrayEquals(plain, decrypted, "DYNAMIC AES/CBC 암복호화 라운드트립 성공해야 한다"); + } + + @Test + @DisplayName("3-2. DYNAMIC — 동일 runtimeContext로 2회 호출 시 HSM은 1회만 호출 (캐시 적용)") + void testCreateExtension_dynamicCached_hsmCalledOnce() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + Map runtimeCtx = new HashMap<>(); + runtimeCtx.put("X-Api-Enc-Key", "cachedKeyValue01"); + + manager.createExtension("DYN_CACHE", runtimeCtx); + manager.createExtension("DYN_CACHE", runtimeCtx); + + verify(mockHsmCryptoService, times(1)).getSecretKey(anyString()); + } + + @Test + @DisplayName("3-3. DYNAMIC — 잘못된 전략 FQCN — IllegalArgumentException") + void testCreateExtension_invalidStrategyFqcn_throwsException() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("DYN_BAD_FQCN", "AES", "CBC", "PKCS5Padding"); + config.setKeyDerivStrategy("com.example.NonExistentStrategy"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + Map runtimeCtx = new HashMap<>(); + runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01"); + + assertThrows(IllegalArgumentException.class, + () -> manager.createExtension("DYN_BAD_FQCN", runtimeCtx), + "존재하지 않는 FQCN이면 IllegalArgumentException이 발생해야 한다"); + } + + @Test + @DisplayName("3-4. DYNAMIC — 동일 FQCN 반복 호출 시 strategyCache에서 재사용") + void testCreateExtension_sameFqcn_strategyCacheReused() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE_STRAT", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + Map runtimeCtx = new HashMap<>(); + runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01"); + + manager.createExtension("DYN_CACHE_STRAT", runtimeCtx); + manager.createExtension("DYN_CACHE_STRAT", runtimeCtx); + + Field stratCacheField = CryptoModuleManager.class.getDeclaredField("strategyCache"); + stratCacheField.setAccessible(true); + Map stratCache = (Map) stratCacheField.get(manager); + + assertEquals(1, stratCache.size(), "동일 FQCN은 strategyCache에 하나만 존재해야 한다"); + } + + @Test + @DisplayName("3-5. DYNAMIC — 다른 runtimeContext 값은 다른 키 도출 (동적 키 캐시 미사용)") + void testCreateExtension_dynamicDifferentContext_differentKey() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("DYN_DIFF", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + Map ctx1 = new HashMap<>(); + ctx1.put("X-Api-Enc-Key", "keyValueAAA00001"); + + Map ctx2 = new HashMap<>(); + ctx2.put("X-Api-Enc-Key", "keyValueBBB00001"); + + manager.createExtension("DYN_DIFF", ctx1); + manager.createExtension("DYN_DIFF", ctx2); + + // 컨텍스트 값이 달라 동적 키 캐시 미사용 → HSM 2회 호출 + verify(mockHsmCryptoService, times(2)).getSecretKey(anyString()); + } + + // ========================================================================= + // 4. reload + // ========================================================================= + + @Test + @DisplayName("4-1. reload(cryptoId) — 변경된 설정 반영") + void testReload_specificId_configUpdated() throws Exception { + CryptoModuleConfig config = buildStaticConfig("RELOAD_MOD", "AES", "CBC", "PKCS5Padding"); + config.setCryptoId("reload-id-001"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + CryptoModuleConfig updated = buildStaticConfig("RELOAD_MOD", "ARIA", "CBC", "PKCS5Padding"); + updated.setCryptoId("reload-id-001"); + when(mockLoader.findById("reload-id-001")).thenReturn(Optional.of(updated)); + + manager.reload("reload-id-001"); + + // 변경된 설정(ARIA)으로 암복호화 가능한지 확인 + byte[] plain = "리로드 테스트".getBytes(StandardCharsets.UTF_8); + CryptoModuleExtension ext = manager.createExtension("RELOAD_MOD"); + assertNotNull(ext); + } + + @Test + @DisplayName("4-2. reload(cryptoId) use_yn=N — configMap에서 제거") + void testReload_specificId_disabled_removedFromMap() throws Exception { + CryptoModuleConfig config = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding"); + config.setCryptoId("rm-id-001"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + CryptoModuleConfig disabled = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding"); + disabled.setCryptoId("rm-id-001"); + disabled.setUseYn("N"); + when(mockLoader.findById("rm-id-001")).thenReturn(Optional.of(disabled)); + + manager.reload("rm-id-001"); + + assertThrows(IllegalArgumentException.class, + () -> manager.createExtension("RM_MOD"), + "비활성화된 모듈은 configMap에서 제거되어야 한다"); + } + + // ========================================================================= + // 헬퍼 + // ========================================================================= + + private void reload() throws Exception { + clearField("configMap"); + clearField("dynamicKeyCache"); + clearField("strategyCache"); + + Method loadAll = CryptoModuleManager.class.getDeclaredMethod("loadAll"); + loadAll.setAccessible(true); + loadAll.invoke(manager); + + reset(mockHsmCryptoService); + SecretKey masterKey = new SecretKeySpec(KEY_128, "AES"); + when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey); + } + + private void clearField(String fieldName) throws Exception { + Field field = CryptoModuleManager.class.getDeclaredField(fieldName); + field.setAccessible(true); + ((Map) field.get(manager)).clear(); + } + + private CryptoModuleConfig buildStaticConfig(String name, String alg, String mode, String padding) { + CryptoModuleConfig c = new CryptoModuleConfig(); + c.setCryptoId(java.util.UUID.randomUUID().toString()); + c.setCryptoName(name); + c.setAlgType(alg); + c.setCipherMode(mode); + c.setPadding(padding); + c.setIvHex(IV_HEX); + c.setKeySourceType("STATIC"); + c.setEncKeyHex(ENC_KEY_HEX); + c.setDecKeyHex(ENC_KEY_HEX); + c.setCacheYn("Y"); + c.setCacheTtlSec(300); + c.setUseYn("Y"); + return c; + } + + private CryptoModuleConfig buildDynamicConfig(String name, String alg, String mode, String padding) { + CryptoModuleConfig c = new CryptoModuleConfig(); + c.setCryptoId(java.util.UUID.randomUUID().toString()); + c.setCryptoName(name); + c.setAlgType(alg); + c.setCipherMode(mode); + c.setPadding(padding); + c.setIvHex(IV_HEX); + c.setKeySourceType("DYNAMIC"); + c.setKeyDerivStrategy("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy"); + c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\",\"contextKey\":\"X-Api-Enc-Key\",\"offset\":\"0\",\"length\":\"16\"}"); + c.setCacheYn("Y"); + c.setCacheTtlSec(300); + c.setUseYn("Y"); + return c; + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + /** MapStruct APT 없이도 동작하는 인라인 매퍼 구현체 */ + private static CryptoModuleConfigMapper testMapper() { + return new CryptoModuleConfigMapper() { + @Override + public CryptoModuleConfigVO toVo(CryptoModuleConfig e) { + CryptoModuleConfigVO vo = new CryptoModuleConfigVO(); + vo.setCryptoId(e.getCryptoId()); + vo.setCryptoName(e.getCryptoName()); + vo.setCryptoDesc(e.getCryptoDesc()); + vo.setAlgType(e.getAlgType()); + vo.setCipherMode(e.getCipherMode()); + vo.setPadding(e.getPadding()); + vo.setIvHex(e.getIvHex()); + vo.setKeySourceType(e.getKeySourceType()); + vo.setEncKeyHex(e.getEncKeyHex()); + vo.setDecKeyHex(e.getDecKeyHex()); + vo.setKeyDerivStrategy(e.getKeyDerivStrategy()); + vo.setKeyDerivParams(e.getKeyDerivParams()); + vo.setCacheYn(e.getCacheYn()); + vo.setCacheTtlSec(e.getCacheTtlSec()); + vo.setUseYn(e.getUseYn()); + return vo; + } + @Override + public CryptoModuleConfig toEntity(CryptoModuleConfigVO vo) { + return null; + } + }; + } +} diff --git a/src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java b/src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java new file mode 100644 index 0000000..dd90d1d --- /dev/null +++ b/src/test/java/com/eactive/eai/common/security/CryptoModuleServiceTest.java @@ -0,0 +1,292 @@ +package com.eactive.eai.common.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.context.support.GenericApplicationContext; + +import com.eactive.eai.common.hsm.HsmCryptoService; +import com.eactive.eai.common.security.loader.CryptoModuleConfigLoader; +import com.eactive.eai.common.security.mapper.CryptoModuleConfigMapper; +import com.eactive.eai.common.util.ApplicationContextProvider; +import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig; + +/** + * CryptoModuleService 단위 테스트 + * CryptoModuleManager를 통한 암복호화 진입점 전체 흐름을 검증한다. + */ +@TestMethodOrder(MethodOrderer.DisplayName.class) +class CryptoModuleServiceTest { + + private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8); + private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8); + private static final String ENC_KEY_HEX = toHex(KEY_128); + private static final String IV_HEX = toHex(IV_16); + + private static GenericApplicationContext ctx; + private static CryptoModuleManager manager; + private static CryptoModuleService service; + private static CryptoModuleConfigLoader mockLoader; + private static HsmCryptoService mockHsm; + + @BeforeAll + static void setUpClass() throws Exception { + mockLoader = mock(CryptoModuleConfigLoader.class); + mockHsm = mock(HsmCryptoService.class); + + SecretKey masterKey = new SecretKeySpec(KEY_128, "AES"); + when(mockHsm.getSecretKey(anyString())).thenReturn(masterKey); + + CryptoModuleConfig aes = buildStatic("AES_SVC", "AES", "CBC", "PKCS5Padding"); + CryptoModuleConfig aria = buildStatic("ARIA_SVC", "ARIA", "CBC", "PKCS5Padding"); + CryptoModuleConfig ecb = buildStaticNoIv("AES_ECB_SVC", "AES", "ECB", "NoPadding"); + CryptoModuleConfig dyn = buildDynamic("DYN_SVC", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(aes, aria, ecb, dyn)); + + Constructor ctor = CryptoModuleManager.class.getDeclaredConstructor(); + ctor.setAccessible(true); + manager = ctor.newInstance(); + service = new CryptoModuleService(); + + ctx = new GenericApplicationContext(); + ctx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader); + ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsm); + ctx.getBeanFactory().registerSingleton("cryptoModuleManager", manager); + ctx.getBeanFactory().registerSingleton("cryptoModuleService", service); + ctx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper()); + ctx.registerBeanDefinition("applicationContextProvider", + BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class) + .getBeanDefinition()); + ctx.refresh(); + + manager.start(); + } + + @AfterAll + static void tearDownClass() { + if (ctx != null) ctx.close(); + } + + // ========================================================================= + // 1. STATIC 키 — encrypt / decrypt + // ========================================================================= + + @Test + @DisplayName("1-1. STATIC AES/CBC — encrypt → decrypt 라운드트립") + void testStaticAes_encryptDecrypt() throws Exception { + byte[] plain = "CryptoModuleService AES 테스트".getBytes(StandardCharsets.UTF_8); + + byte[] encrypted = service.encrypt("AES_SVC", plain); + byte[] decrypted = service.decrypt("AES_SVC", encrypted); + + assertArrayEquals(plain, decrypted); + } + + @Test + @DisplayName("1-2. STATIC ARIA/CBC — encrypt → decrypt 라운드트립") + void testStaticAria_encryptDecrypt() throws Exception { + byte[] plain = "CryptoModuleService ARIA 테스트".getBytes(StandardCharsets.UTF_8); + + byte[] encrypted = service.encrypt("ARIA_SVC", plain); + byte[] decrypted = service.decrypt("ARIA_SVC", encrypted); + + assertArrayEquals(plain, decrypted); + } + + @Test + @DisplayName("1-3. 암호문은 평문과 다른 바이트 배열") + void testEncrypt_ciphertextDiffersFromPlaintext() throws Exception { + byte[] plain = "평문입니다.".getBytes(StandardCharsets.UTF_8); + + byte[] encrypted = service.encrypt("AES_SVC", plain); + + assertFalse(java.util.Arrays.equals(plain, encrypted)); + } + + @Test + @DisplayName("1-4. 미등록 모듈명 — IllegalArgumentException") + void testEncrypt_unknownModule_throwsException() { + byte[] plain = "test".getBytes(StandardCharsets.UTF_8); + + assertThrows(IllegalArgumentException.class, + () -> service.encrypt("UNKNOWN_SVC", plain)); + } + + // ========================================================================= + // 2. DYNAMIC 키 — encrypt / decrypt + // ========================================================================= + + @Test + @DisplayName("2-1. DYNAMIC AES/CBC — runtimeContext 전달 encrypt → decrypt 라운드트립") + void testDynamicAes_encryptDecrypt() throws Exception { + Map runtimeCtx = new HashMap<>(); + runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001"); + + byte[] plain = "DYNAMIC 키 서비스 테스트".getBytes(StandardCharsets.UTF_8); + + byte[] encrypted = service.encrypt("DYN_SVC", runtimeCtx, plain); + byte[] decrypted = service.decrypt("DYN_SVC", runtimeCtx, encrypted); + + assertArrayEquals(plain, decrypted); + } + + @Test + @DisplayName("2-2. DYNAMIC — 다른 runtimeContext 값으로 복호화 시 실패") + void testDynamic_differentContext_decryptFails() throws Exception { + Map encCtx = new HashMap<>(); + encCtx.put("X-Api-Enc-Key", "encryptKeyValue0"); + + Map decCtx = new HashMap<>(); + decCtx.put("X-Api-Enc-Key", "differentKeyVal0"); + + byte[] plain = "키불일치 테스트".getBytes(StandardCharsets.UTF_8); + byte[] encrypted = service.encrypt("DYN_SVC", encCtx, plain); + + assertThrows(Exception.class, + () -> service.decrypt("DYN_SVC", decCtx, encrypted), + "다른 컨텍스트 키로 복호화 시 예외가 발생해야 한다"); + } + + // ========================================================================= + // 3. AES/ECB/NoPadding + // ========================================================================= + + @Test + @DisplayName("3-1. AES/ECB/NoPadding — 16바이트 평문 encrypt → decrypt 라운드트립") + void testEcb_encryptDecrypt_exactBlock() throws Exception { + byte[] plain = "ExactSixteenByte".getBytes(StandardCharsets.UTF_8); // 정확히 16바이트 + + byte[] encrypted = service.encrypt("AES_ECB_SVC", plain); + byte[] decrypted = service.decrypt("AES_ECB_SVC", encrypted); + + assertArrayEquals(plain, decrypted); + } + + @Test + @DisplayName("3-2. AES/ECB/NoPadding — 동일 평문은 항상 동일 암호문 (ECB 특성)") + void testEcb_deterministicOutput() throws Exception { + byte[] plain = "ExactSixteenByte".getBytes(StandardCharsets.UTF_8); + + byte[] enc1 = service.encrypt("AES_ECB_SVC", plain); + byte[] enc2 = service.encrypt("AES_ECB_SVC", plain); + + assertArrayEquals(enc1, enc2, "ECB는 IV 없으므로 동일 평문 → 동일 암호문"); + } + + @Test + @DisplayName("3-3. AES/ECB/NoPadding — 16 배수 아닌 평문 → 예외") + void testEcb_nonBlockAligned_throwsException() { + byte[] plain = "NotSixteenBytes!X".getBytes(StandardCharsets.UTF_8); // 17바이트 + + assertThrows(Exception.class, + () -> service.encrypt("AES_ECB_SVC", plain), + "NoPadding 모드에서 블록 크기 미정렬 평문은 예외가 발생해야 한다"); + } + + // ========================================================================= + // 헬퍼 + // ========================================================================= + + private static CryptoModuleConfig buildStatic(String name, String alg, String mode, String padding) { + CryptoModuleConfig c = new CryptoModuleConfig(); + c.setCryptoId(UUID.randomUUID().toString()); + c.setCryptoName(name); + c.setAlgType(alg); + c.setCipherMode(mode); + c.setPadding(padding); + c.setIvHex(IV_HEX); + c.setKeySourceType("STATIC"); + c.setEncKeyHex(ENC_KEY_HEX); + c.setDecKeyHex(ENC_KEY_HEX); + c.setCacheYn("Y"); + c.setCacheTtlSec(300); + c.setUseYn("Y"); + return c; + } + + private static CryptoModuleConfig buildStaticNoIv(String name, String alg, String mode, String padding) { + CryptoModuleConfig c = new CryptoModuleConfig(); + c.setCryptoId(UUID.randomUUID().toString()); + c.setCryptoName(name); + c.setAlgType(alg); + c.setCipherMode(mode); + c.setPadding(padding); + c.setKeySourceType("STATIC"); + c.setEncKeyHex(ENC_KEY_HEX); + c.setDecKeyHex(ENC_KEY_HEX); + c.setCacheYn("Y"); + c.setCacheTtlSec(300); + c.setUseYn("Y"); + return c; + } + + private static CryptoModuleConfig buildDynamic(String name, String alg, String mode, String padding) { + CryptoModuleConfig c = new CryptoModuleConfig(); + c.setCryptoId(UUID.randomUUID().toString()); + c.setCryptoName(name); + c.setAlgType(alg); + c.setCipherMode(mode); + c.setPadding(padding); + c.setIvHex(IV_HEX); + c.setKeySourceType("DYNAMIC"); + c.setKeyDerivStrategy("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy"); + c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\",\"contextKey\":\"X-Api-Enc-Key\",\"offset\":\"0\",\"length\":\"16\"}"); + c.setCacheYn("Y"); + c.setCacheTtlSec(300); + c.setUseYn("Y"); + return c; + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + /** MapStruct APT 없이도 동작하는 인라인 매퍼 구현체 */ + private static CryptoModuleConfigMapper testMapper() { + return new CryptoModuleConfigMapper() { + @Override + public CryptoModuleConfigVO toVo(CryptoModuleConfig e) { + CryptoModuleConfigVO vo = new CryptoModuleConfigVO(); + vo.setCryptoId(e.getCryptoId()); + vo.setCryptoName(e.getCryptoName()); + vo.setCryptoDesc(e.getCryptoDesc()); + vo.setAlgType(e.getAlgType()); + vo.setCipherMode(e.getCipherMode()); + vo.setPadding(e.getPadding()); + vo.setIvHex(e.getIvHex()); + vo.setKeySourceType(e.getKeySourceType()); + vo.setEncKeyHex(e.getEncKeyHex()); + vo.setDecKeyHex(e.getDecKeyHex()); + vo.setKeyDerivStrategy(e.getKeyDerivStrategy()); + vo.setKeyDerivParams(e.getKeyDerivParams()); + vo.setCacheYn(e.getCacheYn()); + vo.setCacheTtlSec(e.getCacheTtlSec()); + vo.setUseYn(e.getUseYn()); + return vo; + } + @Override + public CryptoModuleConfig toEntity(CryptoModuleConfigVO vo) { + return null; + } + }; + } +} diff --git a/src/test/java/com/eactive/eai/common/security/keyderiv/DerivedKeyTest.java b/src/test/java/com/eactive/eai/common/security/keyderiv/DerivedKeyTest.java new file mode 100644 index 0000000..61098eb --- /dev/null +++ b/src/test/java/com/eactive/eai/common/security/keyderiv/DerivedKeyTest.java @@ -0,0 +1,39 @@ +package com.eactive.eai.common.security.keyderiv; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DerivedKeyTest { + + @Test + @DisplayName("1-1. encKey/decKey 모두 지정 — 각각 독립적으로 반환") + void testBothKeysProvided() { + byte[] encKey = {1, 2, 3}; + byte[] decKey = {4, 5, 6}; + DerivedKey dk = new DerivedKey(encKey, decKey); + + assertArrayEquals(encKey, dk.getEncKey()); + assertArrayEquals(decKey, dk.getDecKey()); + } + + @Test + @DisplayName("1-2. decKey null — encKey로 대체") + void testNullDecKey_fallsBackToEncKey() { + byte[] encKey = {1, 2, 3}; + DerivedKey dk = new DerivedKey(encKey, null); + + assertArrayEquals(encKey, dk.getEncKey()); + assertArrayEquals(encKey, dk.getDecKey(), "decKey가 null이면 encKey와 동일해야 한다"); + } + + @Test + @DisplayName("1-3. 대칭 알고리즘 — encKey == decKey 동일 참조 허용") + void testSymmetricKey_sameReference() { + byte[] key = {0x10, 0x20, 0x30}; + DerivedKey dk = new DerivedKey(key, key); + + assertSame(dk.getEncKey(), dk.getDecKey()); + } +} diff --git a/src/test/java/com/eactive/eai/common/security/keyderiv/HsmContextXorKeyDerivationStrategyTest.java b/src/test/java/com/eactive/eai/common/security/keyderiv/HsmContextXorKeyDerivationStrategyTest.java new file mode 100644 index 0000000..13161b1 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/security/keyderiv/HsmContextXorKeyDerivationStrategyTest.java @@ -0,0 +1,192 @@ +package com.eactive.eai.common.security.keyderiv; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +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.strategy.HsmContextXorKeyDerivationStrategy; +import com.eactive.eai.common.util.ApplicationContextProvider; + +/** + * HsmContextXorKeyDerivationStrategy 단위 테스트 + * HsmCryptoService는 Mockito Mock으로 대체한다. + */ +@TestMethodOrder(MethodOrderer.DisplayName.class) +class HsmContextXorKeyDerivationStrategyTest { + + private static final String HSM_KEY_ALIAS = "MASTER_KEY_AES"; + private static final byte[] MASTER_KEY = "0123456789abcdef".getBytes(java.nio.charset.StandardCharsets.UTF_8); + + private static GenericApplicationContext ctx; + private static HsmCryptoService mockHsmCryptoService; + private static HsmContextXorKeyDerivationStrategy 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 HsmContextXorKeyDerivationStrategy(); + } + + @BeforeEach + void setUpParams() { + params = new HashMap<>(); + params.put("hsmKeyAlias", HSM_KEY_ALIAS); + params.put("contextKey", "X-Api-Enc-Key"); + params.put("offset", "0"); + params.put("length", "16"); + + runtimeContext = new HashMap<>(); + runtimeContext.put("X-Api-Enc-Key", "clientKeyValue01"); + } + + // ========================================================================= + // 1. deriveKey + // ========================================================================= + + @Test + @DisplayName("1-1. 정상 파라미터 — DerivedKey 반환") + void testDeriveKey_validParams_returnsDerivedKey() throws Exception { + DerivedKey dk = strategy.deriveKey(params, runtimeContext); + + assertNotNull(dk); + assertNotNull(dk.getEncKey()); + assertNotNull(dk.getDecKey()); + assertEquals(16, dk.getEncKey().length, "도출된 키 길이는 마스터키 길이(16)여야 한다"); + } + + @Test + @DisplayName("1-2. XOR 계산 결과 검증") + void testDeriveKey_xorResultIsCorrect() throws Exception { + DerivedKey dk = strategy.deriveKey(params, runtimeContext); + + byte[] contextBytes = "clientKeyValue01".getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] expected = new byte[MASTER_KEY.length]; + for (int i = 0; i < MASTER_KEY.length; i++) { + expected[i] = (i < contextBytes.length) + ? (byte) (MASTER_KEY[i] ^ contextBytes[i]) + : MASTER_KEY[i]; + } + + assertArrayEquals(expected, dk.getEncKey(), "XOR 계산 결과가 일치해야 한다"); + } + + @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 값 없음 — 빈 문자열로 처리 (XOR 불변)") + void testDeriveKey_missingContextValue_noXorChange() throws Exception { + runtimeContext.remove("X-Api-Enc-Key"); + DerivedKey dk = strategy.deriveKey(params, runtimeContext); + + assertArrayEquals(MASTER_KEY, dk.getEncKey(), "컨텍스트 값이 없으면 마스터키 그대로 반환되어야 한다"); + } + + @Test + @DisplayName("1-5. offset 지정 — 해당 위치부터 XOR 적용") + void testDeriveKey_withOffset() throws Exception { + params.put("offset", "4"); + params.put("length", "8"); + runtimeContext.put("X-Api-Enc-Key", "01234567890abcde"); + + DerivedKey dk = strategy.deriveKey(params, runtimeContext); + + assertNotNull(dk); + assertEquals(MASTER_KEY.length, dk.getEncKey().length); + } + + @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이 발생해야 한다"); + } + + @Test + @DisplayName("1-8. 다른 runtimeContext 값 — 다른 DerivedKey 생성") + void testDeriveKey_differentContextValue_differentKey() throws Exception { + DerivedKey dk1 = strategy.deriveKey(params, runtimeContext); + + runtimeContext.put("X-Api-Enc-Key", "differentValue00"); + DerivedKey dk2 = strategy.deriveKey(params, runtimeContext); + + assertFalse(java.util.Arrays.equals(dk1.getEncKey(), dk2.getEncKey()), + "컨텍스트 값이 다르면 도출된 키도 달라야 한다"); + } + + // ========================================================================= + // 2. buildCacheKey + // ========================================================================= + + @Test + @DisplayName("2-1. buildCacheKey — cryptoName:contextValue 형식") + void testBuildCacheKey_format() { + String cacheKey = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext); + + assertEquals("CRYPTO_A:clientKeyValue01", cacheKey); + } + + @Test + @DisplayName("2-2. buildCacheKey — contextKey 값 없으면 빈 문자열") + void testBuildCacheKey_missingContextValue_emptyString() { + runtimeContext.remove("X-Api-Enc-Key"); + 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); + } +}