diff --git a/postman/CryptoModuleManage.postman_collection.json b/postman/CryptoModuleManage.postman_collection.json new file mode 100644 index 0000000..2dc6b84 --- /dev/null +++ b/postman/CryptoModuleManage.postman_collection.json @@ -0,0 +1,391 @@ +{ + "info": { + "_postman_id": "b7e2e1d0-3c1a-4b0a-9d2e-crypto-manage-0001", + "name": "CryptoModuleManage API (/manage/crypto)", + "description": "CryptoModuleManager 기반 암호화모듈 진단 및 암복호화 테스트 API 샘플 모음.\n\n사용 전 확인사항:\n1. Collection Variables 의 baseUrl 을 실제 서버 주소로 변경\n2. cryptoName / dynamicCryptoName 을 DB(crypto_module_config)에 실제 등록된 모듈명으로 변경\n3. DYNAMIC 모듈은 keyDerivParams 의 contextKey 값과 dynamicContextKey 변수가 일치해야 함 (예: X-Api-Enc-Key)\n\n주의: 이 API는 원본 암호키(encKeyHex/decKeyHex/ivHex)를 절대 응답에 포함하지 않는다. 진단 응답은 메타데이터와 키 도출 전략(KeyDerivationStrategy) 로드 상태만 노출한다.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "baseUrl", "value": "http://localhost:8080", "type": "string" }, + { "key": "cryptoName", "value": "SAMPLE_STATIC", "type": "string", "description": "STATIC 키 방식으로 등록된 실제 cryptoName으로 교체" }, + { "key": "dynamicCryptoName", "value": "SAMPLE_DYNAMIC", "type": "string", "description": "DYNAMIC 키 방식으로 등록된 실제 cryptoName으로 교체" }, + { "key": "dynamicContextKey", "value": "X-Api-Enc-Key", "type": "string", "description": "DYNAMIC 모듈의 key_deriv_params.contextKey 값과 일치해야 함" }, + { "key": "dynamicContextValue", "value": "clientKeyValue01", "type": "string" }, + { "key": "plainText", "value": "테스트 평문입니다", "type": "string" }, + { "key": "plainTextBase64", "value": "", "type": "string" }, + { "key": "staticCipherTextBase64", "value": "", "type": "string" }, + { "key": "dynamicCipherTextBase64", "value": "", "type": "string" } + ], + "item": [ + { + "name": "1. 진단 (Diagnostics)", + "item": [ + { + "name": "전체 암호화모듈 목록 조회", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/manage/crypto/list", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "list"] + }, + "description": "등록된 전체 암호화모듈의 진단정보(메타데이터)를 반환한다. 원본 키는 포함되지 않는다." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('status 200', () => pm.response.to.have.status(200));", + "pm.test('success = true', () => pm.expect(json.success).to.eql(true));", + "pm.test('data는 배열', () => pm.expect(json.data).to.be.an('array'));", + "pm.test('원본 키 필드가 응답에 없어야 함', () => {", + " const body = pm.response.text();", + " pm.expect(body).to.not.include('encKeyHex');", + " pm.expect(body).to.not.include('decKeyHex');", + "});" + ] + } + } + ] + }, + { + "name": "단일 암호화모듈 조회", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/manage/crypto/{{cryptoName}}", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "{{cryptoName}}"] + }, + "description": "cryptoName 하나의 진단정보를 조회한다. STATIC 모듈은 keyDerivStrategy가 null, strategyLoaded가 false로 내려온다." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('status 200', () => pm.response.to.have.status(200));", + "pm.test('success = true', () => pm.expect(json.success).to.eql(true));", + "pm.test('cryptoName 일치', () => pm.expect(json.data.cryptoName).to.eql(pm.collectionVariables.get('cryptoName')));" + ] + } + } + ] + }, + { + "name": "DYNAMIC 모듈 조회 (전략 로드 확인)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/manage/crypto/{{dynamicCryptoName}}", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "{{dynamicCryptoName}}"] + }, + "description": "DYNAMIC 모듈은 resolveStrategy를 통해 키 도출 전략(KeyDerivationStrategy) 클래스가 정상 로드되는지 strategyLoaded/strategyClassName으로 확인할 수 있다." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('success = true', () => pm.expect(json.success).to.eql(true));", + "pm.test('keySourceType = DYNAMIC', () => pm.expect(json.data.keySourceType).to.eql('DYNAMIC'));", + "pm.test('strategyLoaded = true', () => pm.expect(json.data.strategyLoaded).to.eql(true));", + "pm.test('strategyClassName 존재', () => pm.expect(json.data.strategyClassName).to.be.a('string'));" + ] + } + } + ] + }, + { + "name": "동적 키 캐시 목록 조회", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/manage/crypto/cache/dynamic-keys", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "cache", "dynamic-keys"] + }, + "description": "DYNAMIC 키 방식 모듈이 캐시한 키의 캐시-키 목록(예: 'cryptoName:contextValue')을 반환한다. 원본 키 값이 아니다. 아래 'DYNAMIC 키' 폴더의 암호화 요청을 먼저 실행한 뒤 호출하면 목록이 채워진다." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('success = true', () => pm.expect(json.success).to.eql(true));", + "pm.test('data는 배열', () => pm.expect(json.data).to.be.an('array'));" + ] + } + } + ] + } + ] + }, + { + "name": "2. STATIC 키 암복호화 테스트", + "item": [ + { + "name": "암호화 (encrypt)", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"plainTextBase64\": \"{{plainTextBase64}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/manage/crypto/{{cryptoName}}/test/encrypt", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "{{cryptoName}}", "test", "encrypt"] + }, + "description": "STATIC 키 방식 모듈로 평문(Base64)을 암호화한다. runtimeContext 없이 호출한다." + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const plainText = pm.collectionVariables.get('plainText');", + "pm.collectionVariables.set('plainTextBase64', btoa(unescape(encodeURIComponent(plainText))));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('status 200', () => pm.response.to.have.status(200));", + "pm.test('success = true', () => pm.expect(json.success).to.eql(true));", + "pm.test('cipherTextBase64 존재', () => pm.expect(json.data.cipherTextBase64).to.be.a('string'));", + "pm.test('평문 base64와 달라야 함', () => pm.expect(json.data.cipherTextBase64).to.not.eql(pm.collectionVariables.get('plainTextBase64')));", + "pm.collectionVariables.set('staticCipherTextBase64', json.data.cipherTextBase64);" + ] + } + } + ] + }, + { + "name": "복호화 (decrypt) — 위 암호화 결과 사용", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cipherTextBase64\": \"{{staticCipherTextBase64}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/manage/crypto/{{cryptoName}}/test/decrypt", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "{{cryptoName}}", "test", "decrypt"] + }, + "description": "바로 위 '암호화 (encrypt)' 요청에서 저장한 staticCipherTextBase64를 복호화하여 원문과 일치하는지 확인한다. 반드시 암호화 요청을 먼저 실행할 것." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('status 200', () => pm.response.to.have.status(200));", + "pm.test('success = true', () => pm.expect(json.success).to.eql(true));", + "const decoded = decodeURIComponent(escape(atob(json.data.plainTextBase64)));", + "pm.test('원문과 일치 (라운드트립)', () => pm.expect(decoded).to.eql(pm.collectionVariables.get('plainText')));" + ] + } + } + ] + } + ] + }, + { + "name": "3. DYNAMIC 키 암복호화 테스트", + "item": [ + { + "name": "암호화 (encrypt, runtimeContext 포함)", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"plainTextBase64\": \"{{plainTextBase64}}\",\n \"runtimeContext\": {\n \"{{dynamicContextKey}}\": \"{{dynamicContextValue}}\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/manage/crypto/{{dynamicCryptoName}}/test/encrypt", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "{{dynamicCryptoName}}", "test", "encrypt"] + }, + "description": "DYNAMIC 키 방식 모듈로 runtimeContext(예: 헤더값)를 전달하여 키를 도출한 뒤 암호화한다. dynamicContextKey는 DB에 등록된 key_deriv_params.contextKey와 일치해야 한다." + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const plainText = pm.collectionVariables.get('plainText');", + "pm.collectionVariables.set('plainTextBase64', btoa(unescape(encodeURIComponent(plainText))));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('status 200', () => pm.response.to.have.status(200));", + "pm.test('success = true', () => pm.expect(json.success).to.eql(true));", + "pm.test('cipherTextBase64 존재', () => pm.expect(json.data.cipherTextBase64).to.be.a('string'));", + "pm.collectionVariables.set('dynamicCipherTextBase64', json.data.cipherTextBase64);" + ] + } + } + ] + }, + { + "name": "복호화 (decrypt, 동일 runtimeContext 필요)", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cipherTextBase64\": \"{{dynamicCipherTextBase64}}\",\n \"runtimeContext\": {\n \"{{dynamicContextKey}}\": \"{{dynamicContextValue}}\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/manage/crypto/{{dynamicCryptoName}}/test/decrypt", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "{{dynamicCryptoName}}", "test", "decrypt"] + }, + "description": "암호화 시 사용한 것과 동일한 runtimeContext를 전달해야 동일한 키가 도출되어 복호화에 성공한다. dynamicContextValue를 바꿔서 보내면 키가 달라져 복호화가 실패(예외)하는 것도 확인해볼 수 있다." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('status 200', () => pm.response.to.have.status(200));", + "pm.test('success = true', () => pm.expect(json.success).to.eql(true));", + "const decoded = decodeURIComponent(escape(atob(json.data.plainTextBase64)));", + "pm.test('원문과 일치 (라운드트립)', () => pm.expect(decoded).to.eql(pm.collectionVariables.get('plainText')));" + ] + } + } + ] + } + ] + }, + { + "name": "4. 에러 케이스", + "item": [ + { + "name": "미등록 cryptoName 조회 → success=false", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/manage/crypto/NOT_REGISTERED_MODULE", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "NOT_REGISTERED_MODULE"] + }, + "description": "존재하지 않는 cryptoName 조회 시 HTTP 200 + success=false + message에 사유가 담겨 반환되는지 확인한다 (예외가 그대로 500으로 노출되지 않음)." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('status 200', () => pm.response.to.have.status(200));", + "pm.test('success = false', () => pm.expect(json.success).to.eql(false));", + "pm.test('message에 모듈명 포함', () => pm.expect(json.message).to.include('NOT_REGISTERED_MODULE'));" + ] + } + } + ] + }, + { + "name": "잘못된 Base64로 복호화 → success=false", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cipherTextBase64\": \"not-valid-base64!!\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/manage/crypto/{{cryptoName}}/test/decrypt", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "{{cryptoName}}", "test", "decrypt"] + }, + "description": "Base64 형식이 아닌 문자열을 전달하면 success=false로 처리되고 서버가 500으로 죽지 않는지 확인한다." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('status 200', () => pm.response.to.have.status(200));", + "pm.test('success = false', () => pm.expect(json.success).to.eql(false));", + "pm.test('message 존재', () => pm.expect(json.message).to.be.a('string'));" + ] + } + } + ] + }, + { + "name": "다른 runtimeContext로 DYNAMIC 복호화 시도 → 실패 확인", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cipherTextBase64\": \"{{dynamicCipherTextBase64}}\",\n \"runtimeContext\": {\n \"{{dynamicContextKey}}\": \"differentContextValue\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/manage/crypto/{{dynamicCryptoName}}/test/decrypt", + "host": ["{{baseUrl}}"], + "path": ["manage", "crypto", "{{dynamicCryptoName}}", "test", "decrypt"] + }, + "description": "'3. DYNAMIC 키' 폴더의 암호화를 먼저 실행해 dynamicCipherTextBase64를 채운 뒤, 암호화 때와 다른 runtimeContext 값으로 복호화를 시도한다. 키가 달라 padding/복호화 오류로 success=false가 되는 것을 확인한다 (패딩 모드에 따라 우연히 성공할 수도 있으니 참고용)." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "const json = pm.response.json();", + "pm.test('status 200', () => pm.response.to.have.status(200));", + "console.log('다른 컨텍스트 복호화 결과:', JSON.stringify(json));" + ] + } + } + ] + } + ] + } + ] +} diff --git a/src/main/java/com/eactive/eai/common/security/CryptoModuleDiagnosticDTO.java b/src/main/java/com/eactive/eai/common/security/CryptoModuleDiagnosticDTO.java new file mode 100644 index 0000000..fb35021 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/security/CryptoModuleDiagnosticDTO.java @@ -0,0 +1,28 @@ +package com.eactive.eai.common.security; + +import lombok.Data; + +/** + * CryptoModuleManager 진단정보 DTO. + * + * 원본 암호키(encKey/decKey) 및 IV는 절대 포함하지 않는다. 메타데이터와 + * 키 도출 전략(KeyDerivationStrategy) 로드 상태만 담는다. + */ +@Data +public class CryptoModuleDiagnosticDTO { + + String cryptoName; + String cryptoDesc; + String algType; + String cipherMode; + String padding; + boolean hasIv; + String keySourceType; + String keyDerivStrategy; + boolean strategyLoaded; + String strategyClassName; + String strategyLoadError; + String cacheYn; + Integer cacheTtlSec; + String useYn; +} diff --git a/src/main/java/com/eactive/eai/common/security/CryptoModuleManager.java b/src/main/java/com/eactive/eai/common/security/CryptoModuleManager.java index 0144d51..a942a16 100644 --- a/src/main/java/com/eactive/eai/common/security/CryptoModuleManager.java +++ b/src/main/java/com/eactive/eai/common/security/CryptoModuleManager.java @@ -1,5 +1,6 @@ package com.eactive.eai.common.security; +import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -153,6 +154,58 @@ public class CryptoModuleManager implements Lifecycle { return buildExtension(vo, iv, derivedKey.getEncKey(), derivedKey.getDecKey()); } + /** + * 등록된 전체 암호화모듈의 진단정보를 반환한다. + * 원본 키(encKey/decKey/ivHex)는 포함하지 않는다. + */ + public List describeAll() { + List result = new ArrayList<>(); + for (String cryptoName : configMap.keySet()) { + result.add(describe(cryptoName)); + } + return result; + } + + /** + * 단일 암호화모듈의 진단정보를 반환한다. DYNAMIC 키 방식인 경우 resolveStrategy를 통해 + * 전략 클래스 로드 성공 여부를 함께 확인한다. 원본 키(encKey/decKey/ivHex)는 포함하지 않는다. + */ + public CryptoModuleDiagnosticDTO describe(String cryptoName) { + CryptoModuleConfigVO vo = getVO(cryptoName); + + CryptoModuleDiagnosticDTO dto = new CryptoModuleDiagnosticDTO(); + dto.setCryptoName(vo.getCryptoName()); + dto.setCryptoDesc(vo.getCryptoDesc()); + dto.setAlgType(vo.getAlgType()); + dto.setCipherMode(vo.getCipherMode()); + dto.setPadding(vo.getPadding()); + dto.setHasIv(vo.getIvHex() != null); + dto.setKeySourceType(vo.getKeySourceType()); + dto.setCacheYn(vo.getCacheYn()); + dto.setCacheTtlSec(vo.getCacheTtlSec()); + dto.setUseYn(vo.getUseYn()); + + if (!"STATIC".equalsIgnoreCase(vo.getKeySourceType()) && vo.getKeyDerivStrategy() != null) { + dto.setKeyDerivStrategy(vo.getKeyDerivStrategy()); + try { + KeyDerivationStrategy strategy = resolveStrategy(vo.getKeyDerivStrategy()); + dto.setStrategyLoaded(true); + dto.setStrategyClassName(strategy.getClass().getName()); + } catch (Exception e) { + dto.setStrategyLoaded(false); + dto.setStrategyLoadError(e.getMessage()); + } + } + return dto; + } + + /** + * 동적 키 캐시에 존재하는 캐시 키 목록을 반환한다. (전략별 buildCacheKey 결과이며, 원본 키 값이 아니다) + */ + public List listDynamicCacheKeys() { + return new ArrayList<>(dynamicKeyCache.keySet()); + } + private CryptoModuleConfigVO getVO(String cryptoName) { CryptoModuleConfigVO vo = configMap.get(cryptoName); if (vo == null) { diff --git a/src/main/java/com/eactive/eai/manage/crypto/CryptoModuleManageController.java b/src/main/java/com/eactive/eai/manage/crypto/CryptoModuleManageController.java new file mode 100644 index 0000000..abfd2db --- /dev/null +++ b/src/main/java/com/eactive/eai/manage/crypto/CryptoModuleManageController.java @@ -0,0 +1,96 @@ +package com.eactive.eai.manage.crypto; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.eactive.eai.common.security.CryptoModuleDiagnosticDTO; + +/** + * 암호화모듈(CryptoModuleManager) 진단 및 암복호화 테스트 API. + * + * 원본 암호키(encKey/decKey/ivHex)는 응답에 포함하지 않는다. 메타데이터와 + * 키 도출 전략(KeyDerivationStrategy) 로드 상태만 노출한다. + * + * GET /manage/crypto/list → 등록된 전체 암호화모듈 진단정보 + * GET /manage/crypto/{cryptoName} → 단일 암호화모듈 진단정보 + * GET /manage/crypto/cache/dynamic-keys → 동적 키 캐시 키 목록 (원본 키 아님) + * POST /manage/crypto/{cryptoName}/test/encrypt → 테스트 암호화 (Base64 입출력) + * POST /manage/crypto/{cryptoName}/test/decrypt → 테스트 복호화 (Base64 입출력) + */ +@RestController +@RequestMapping("/manage/crypto") +public class CryptoModuleManageController { + + @Autowired + private CryptoModuleManageService cryptoModuleManageService; + + @GetMapping("/list") + public ResponseEntity listAll() { + List list = cryptoModuleManageService.listAll(); + Map result = new HashMap<>(); + result.put("success", true); + result.put("data", list); + result.put("count", list.size()); + return ResponseEntity.ok(result); + } + + @GetMapping("/{cryptoName}") + public ResponseEntity get(@PathVariable String cryptoName) { + return respond(() -> cryptoModuleManageService.get(cryptoName)); + } + + @GetMapping("/cache/dynamic-keys") + public ResponseEntity listDynamicCacheKeys() { + List keys = cryptoModuleManageService.listDynamicCacheKeys(); + Map result = new HashMap<>(); + result.put("success", true); + result.put("data", keys); + result.put("count", keys.size()); + return ResponseEntity.ok(result); + } + + @PostMapping("/{cryptoName}/test/encrypt") + public ResponseEntity testEncrypt(@PathVariable String cryptoName, @RequestBody CryptoTestRequestDTO request) { + return respond(() -> { + String cipherTextBase64 = cryptoModuleManageService.testEncrypt(cryptoName, + request.getRuntimeContext(), request.getPlainTextBase64(), request.getAadBase64()); + Map data = new HashMap<>(); + data.put("cipherTextBase64", cipherTextBase64); + return data; + }); + } + + @PostMapping("/{cryptoName}/test/decrypt") + public ResponseEntity testDecrypt(@PathVariable String cryptoName, @RequestBody CryptoTestRequestDTO request) { + return respond(() -> { + String plainTextBase64 = cryptoModuleManageService.testDecrypt(cryptoName, + request.getRuntimeContext(), request.getCipherTextBase64(), request.getAadBase64()); + Map data = new HashMap<>(); + data.put("plainTextBase64", plainTextBase64); + return data; + }); + } + + private ResponseEntity respond(Callable action) { + Map result = new HashMap<>(); + try { + result.put("success", true); + result.put("data", action.call()); + } catch (Exception e) { + result.put("success", false); + result.put("message", e.getMessage()); + } + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/com/eactive/eai/manage/crypto/CryptoModuleManageService.java b/src/main/java/com/eactive/eai/manage/crypto/CryptoModuleManageService.java new file mode 100644 index 0000000..79db4bc --- /dev/null +++ b/src/main/java/com/eactive/eai/manage/crypto/CryptoModuleManageService.java @@ -0,0 +1,61 @@ +package com.eactive.eai.manage.crypto; + +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import com.eactive.eai.common.security.CryptoModuleDiagnosticDTO; +import com.eactive.eai.common.security.CryptoModuleExtension; +import com.eactive.eai.common.security.CryptoModuleManager; + +/** + * CryptoModuleManager 진단 및 암복호화 테스트를 위임하는 서비스. + * + * createExtension(cryptoName, runtimeContext) 는 STATIC 키 방식인 경우 내부에서 + * createExtension(cryptoName) 으로 위임하므로, 테스트 시 runtimeContext 유무와 무관하게 + * 동일 메서드를 사용한다. + */ +@Service +public class CryptoModuleManageService { + + public List listAll() { + return CryptoModuleManager.getInstance().describeAll(); + } + + public CryptoModuleDiagnosticDTO get(String cryptoName) { + return CryptoModuleManager.getInstance().describe(cryptoName); + } + + public List listDynamicCacheKeys() { + return CryptoModuleManager.getInstance().listDynamicCacheKeys(); + } + + public String testEncrypt(String cryptoName, Map runtimeContext, + String plainTextBase64, String aadBase64) throws Exception { + byte[] plain = Base64.getDecoder().decode(plainTextBase64); + CryptoModuleExtension ext = CryptoModuleManager.getInstance() + .createExtension(cryptoName, runtimeContext(runtimeContext)); + byte[] cipher = aadBase64 != null + ? ext.encrypt(plain, Base64.getDecoder().decode(aadBase64)) + : ext.encrypt(plain); + return Base64.getEncoder().encodeToString(cipher); + } + + public String testDecrypt(String cryptoName, Map runtimeContext, + String cipherTextBase64, String aadBase64) throws Exception { + byte[] cipherBytes = Base64.getDecoder().decode(cipherTextBase64); + CryptoModuleExtension ext = CryptoModuleManager.getInstance() + .createExtension(cryptoName, runtimeContext(runtimeContext)); + byte[] plain = aadBase64 != null + ? ext.decrypt(cipherBytes, Base64.getDecoder().decode(aadBase64)) + : ext.decrypt(cipherBytes); + return Base64.getEncoder().encodeToString(plain); + } + + private Map runtimeContext(Map runtimeContext) { + return runtimeContext != null ? runtimeContext : Collections.emptyMap(); + } +} diff --git a/src/main/java/com/eactive/eai/manage/crypto/CryptoTestRequestDTO.java b/src/main/java/com/eactive/eai/manage/crypto/CryptoTestRequestDTO.java new file mode 100644 index 0000000..bbd2ad6 --- /dev/null +++ b/src/main/java/com/eactive/eai/manage/crypto/CryptoTestRequestDTO.java @@ -0,0 +1,17 @@ +package com.eactive.eai.manage.crypto; + +import java.util.Map; + +import lombok.Data; + +/** + * 암복호화 테스트 요청 DTO. 평문/암문은 Base64로 주고받는다. + */ +@Data +public class CryptoTestRequestDTO { + + String plainTextBase64; + String cipherTextBase64; + String aadBase64; + Map runtimeContext; +} diff --git a/src/test/java/com/eactive/eai/common/security/CryptoModuleManagerTest.java b/src/test/java/com/eactive/eai/common/security/CryptoModuleManagerTest.java index ec6e23c..862c36d 100644 --- a/src/test/java/com/eactive/eai/common/security/CryptoModuleManagerTest.java +++ b/src/test/java/com/eactive/eai/common/security/CryptoModuleManagerTest.java @@ -10,6 +10,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -295,6 +296,97 @@ class CryptoModuleManagerTest { "비활성화된 모듈은 configMap에서 제거되어야 한다"); } + // ========================================================================= + // 5. 진단정보 — describe / describeAll / listDynamicCacheKeys + // ========================================================================= + + @Test + @DisplayName("5-1. describe() STATIC — keyDerivStrategy 없음, 원본 키 필드 없음") + void testDescribe_static_noStrategyInfo() throws Exception { + CryptoModuleConfig config = buildStaticConfig("DESC_STATIC", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + CryptoModuleDiagnosticDTO dto = manager.describe("DESC_STATIC"); + + assertEquals("DESC_STATIC", dto.getCryptoName()); + assertEquals("STATIC", dto.getKeySourceType()); + assertTrue(dto.isHasIv()); + assertNull(dto.getKeyDerivStrategy(), "STATIC 모듈은 keyDerivStrategy가 없어야 한다"); + assertFalse(dto.isStrategyLoaded(), "STATIC 모듈은 전략을 로드하지 않는다"); + assertFalse(dto.toString().contains(ENC_KEY_HEX), "원본 키(hex)가 진단정보에 노출되면 안 된다"); + } + + @Test + @DisplayName("5-2. describe() DYNAMIC 정상 FQCN — strategyLoaded=true, strategyClassName 존재") + void testDescribe_dynamic_validFqcn_strategyLoaded() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("DESC_DYN", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + CryptoModuleDiagnosticDTO dto = manager.describe("DESC_DYN"); + + assertEquals("DYNAMIC", dto.getKeySourceType()); + assertEquals("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy", + dto.getKeyDerivStrategy()); + assertTrue(dto.isStrategyLoaded()); + assertEquals("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy", + dto.getStrategyClassName()); + assertNull(dto.getStrategyLoadError()); + } + + @Test + @DisplayName("5-3. describe() DYNAMIC 잘못된 FQCN — strategyLoaded=false, strategyLoadError 존재") + void testDescribe_dynamic_invalidFqcn_strategyLoadFailed() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("DESC_BAD_FQCN", "AES", "CBC", "PKCS5Padding"); + config.setKeyDerivStrategy("com.example.NonExistentStrategy"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + CryptoModuleDiagnosticDTO dto = manager.describe("DESC_BAD_FQCN"); + + assertFalse(dto.isStrategyLoaded()); + assertNotNull(dto.getStrategyLoadError()); + } + + @Test + @DisplayName("5-4. describe() 미등록 cryptoName — IllegalArgumentException") + void testDescribe_unknownName_throwsException() { + assertThrows(IllegalArgumentException.class, () -> manager.describe("UNKNOWN_MODULE")); + } + + @Test + @DisplayName("5-5. describeAll() — 등록된 모듈 수만큼 반환") + void testDescribeAll_returnsAllRegisteredModules() throws Exception { + CryptoModuleConfig c1 = buildStaticConfig("DESC_ALL_1", "AES", "CBC", "PKCS5Padding"); + CryptoModuleConfig c2 = buildDynamicConfig("DESC_ALL_2", "ARIA", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(c1, c2)); + reload(); + + List all = manager.describeAll(); + + assertEquals(2, all.size()); + assertTrue(all.stream().anyMatch(d -> "DESC_ALL_1".equals(d.getCryptoName()))); + assertTrue(all.stream().anyMatch(d -> "DESC_ALL_2".equals(d.getCryptoName()))); + } + + @Test + @DisplayName("5-6. listDynamicCacheKeys() — DYNAMIC 키 생성 후 캐시키 노출 (원본 키 아님)") + void testListDynamicCacheKeys_afterCreateExtension_containsCacheKey() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("DESC_CACHE_KEYS", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + Map runtimeCtx = new HashMap<>(); + runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01"); + manager.createExtension("DESC_CACHE_KEYS", runtimeCtx); + + List cacheKeys = manager.listDynamicCacheKeys(); + + assertEquals(1, cacheKeys.size()); + assertEquals("DESC_CACHE_KEYS:clientKeyValue01", cacheKeys.get(0)); + } + // ========================================================================= // 헬퍼 // ========================================================================= diff --git a/src/test/java/com/eactive/eai/manage/crypto/CryptoModuleManageControllerTest.java b/src/test/java/com/eactive/eai/manage/crypto/CryptoModuleManageControllerTest.java new file mode 100644 index 0000000..5a162e8 --- /dev/null +++ b/src/test/java/com/eactive/eai/manage/crypto/CryptoModuleManageControllerTest.java @@ -0,0 +1,189 @@ +package com.eactive.eai.manage.crypto; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import com.eactive.eai.common.security.CryptoModuleDiagnosticDTO; + +/** + * CryptoModuleManageController 단위 테스트. + * + *

Spring MVC를 기동하지 않고 컨트롤러를 직접 인스턴스화하여 + * 응답 구조(success, data, count, message)를 검증한다. + */ +class CryptoModuleManageControllerTest { + + private CryptoModuleManageController controller; + private CryptoModuleManageService mockService; + + @BeforeEach + void setUp() { + controller = new CryptoModuleManageController(); + mockService = mock(CryptoModuleManageService.class); + ReflectionTestUtils.setField(controller, "cryptoModuleManageService", mockService); + } + + // ------------------------------------------------------------------------- + // Helper + // ------------------------------------------------------------------------- + + private CryptoModuleDiagnosticDTO makeDTO(String name, String keySourceType) { + CryptoModuleDiagnosticDTO dto = new CryptoModuleDiagnosticDTO(); + dto.setCryptoName(name); + dto.setKeySourceType(keySourceType); + dto.setAlgType("AES"); + dto.setCipherMode("CBC"); + return dto; + } + + @SuppressWarnings("unchecked") + private Map body(ResponseEntity response) { + return (Map) response.getBody(); + } + + // ========================================================================= + // 목록 조회 — listAll + // ========================================================================= + + @Test + @DisplayName("listAll() — 등록된 모듈 목록과 개수 반환") + void listAll_returnsListAndCount() { + List list = Arrays.asList( + makeDTO("MOD_A", "STATIC"), + makeDTO("MOD_B", "DYNAMIC") + ); + when(mockService.listAll()).thenReturn(list); + + Map body = body(controller.listAll()); + + assertEquals(Boolean.TRUE, body.get("success")); + assertSame(list, body.get("data")); + assertEquals(2, body.get("count")); + } + + @Test + @DisplayName("listAll() — 빈 목록이면 count=0") + void listAll_emptyList_countZero() { + when(mockService.listAll()).thenReturn(Collections.emptyList()); + + Map body = body(controller.listAll()); + + assertEquals(Boolean.TRUE, body.get("success")); + assertEquals(0, body.get("count")); + } + + // ========================================================================= + // 단일 조회 — get + // ========================================================================= + + @Test + @DisplayName("get() — 정상 조회 시 success=true, data 포함") + void get_found_successTrueAndDataPresent() { + CryptoModuleDiagnosticDTO dto = makeDTO("MOD_A", "STATIC"); + when(mockService.get("MOD_A")).thenReturn(dto); + + ResponseEntity response = controller.get("MOD_A"); + Map body = body(response); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals(Boolean.TRUE, body.get("success")); + assertSame(dto, body.get("data")); + assertFalse(body.containsKey("message")); + } + + @Test + @DisplayName("get() — 미등록 cryptoName 예외 발생 시 success=false, message에 원인 포함") + void get_unknownName_successFalseWithMessage() { + when(mockService.get("GHOST")).thenThrow(new IllegalArgumentException("암호화모듈 미등록: GHOST")); + + Map body = body(controller.get("GHOST")); + + assertEquals(Boolean.FALSE, body.get("success")); + assertTrue(body.get("message").toString().contains("GHOST")); + assertFalse(body.containsKey("data")); + } + + // ========================================================================= + // 동적 키 캐시 목록 — listDynamicCacheKeys + // ========================================================================= + + @Test + @DisplayName("listDynamicCacheKeys() — 캐시키 목록과 개수 반환") + void listDynamicCacheKeys_returnsListAndCount() { + List keys = Arrays.asList("MOD_A:ctx1", "MOD_A:ctx2"); + when(mockService.listDynamicCacheKeys()).thenReturn(keys); + + Map body = body(controller.listDynamicCacheKeys()); + + assertEquals(Boolean.TRUE, body.get("success")); + assertSame(keys, body.get("data")); + assertEquals(2, body.get("count")); + } + + // ========================================================================= + // 암복호화 테스트 — testEncrypt / testDecrypt + // ========================================================================= + + @Test + @DisplayName("testEncrypt() — 정상 암호화 시 success=true, cipherTextBase64 포함") + void testEncrypt_success_returnsCipherTextBase64() throws Exception { + CryptoTestRequestDTO request = new CryptoTestRequestDTO(); + request.setPlainTextBase64("cGxhaW4="); + when(mockService.testEncrypt(eq("MOD_A"), isNull(), eq("cGxhaW4="), isNull())) + .thenReturn("Y2lwaGVy"); + + Map body = body(controller.testEncrypt("MOD_A", request)); + + assertEquals(Boolean.TRUE, body.get("success")); + @SuppressWarnings("unchecked") + Map data = (Map) body.get("data"); + assertEquals("Y2lwaGVy", data.get("cipherTextBase64")); + } + + @Test + @DisplayName("testEncrypt() — 서비스 예외 발생 시 success=false, message 포함") + void testEncrypt_serviceThrows_successFalseWithMessage() throws Exception { + CryptoTestRequestDTO request = new CryptoTestRequestDTO(); + request.setPlainTextBase64("not-valid-base64!!"); + when(mockService.testEncrypt(anyString(), any(), anyString(), any())) + .thenThrow(new IllegalArgumentException("Illegal base64 character")); + + Map body = body(controller.testEncrypt("MOD_A", request)); + + assertEquals(Boolean.FALSE, body.get("success")); + assertNotNull(body.get("message")); + } + + @Test + @DisplayName("testDecrypt() — runtimeContext 전달 시 서비스에 그대로 위임") + void testDecrypt_withRuntimeContext_delegatesToService() throws Exception { + Map runtimeContext = new HashMap<>(); + runtimeContext.put("X-Api-Enc-Key", "clientKeyValue01"); + + CryptoTestRequestDTO request = new CryptoTestRequestDTO(); + request.setCipherTextBase64("Y2lwaGVy"); + request.setRuntimeContext(runtimeContext); + when(mockService.testDecrypt("MOD_B", runtimeContext, "Y2lwaGVy", null)) + .thenReturn("cGxhaW4="); + + Map body = body(controller.testDecrypt("MOD_B", request)); + + assertEquals(Boolean.TRUE, body.get("success")); + @SuppressWarnings("unchecked") + Map data = (Map) body.get("data"); + assertEquals("cGxhaW4=", data.get("plainTextBase64")); + verify(mockService).testDecrypt("MOD_B", runtimeContext, "Y2lwaGVy", null); + } +} diff --git a/src/test/java/com/eactive/eai/manage/crypto/CryptoModuleManageServiceTest.java b/src/test/java/com/eactive/eai/manage/crypto/CryptoModuleManageServiceTest.java new file mode 100644 index 0000000..fdc77c8 --- /dev/null +++ b/src/test/java/com/eactive/eai/manage/crypto/CryptoModuleManageServiceTest.java @@ -0,0 +1,291 @@ +package com.eactive.eai.manage.crypto; + +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.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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.Test; +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.CryptoModuleConfigVO; +import com.eactive.eai.common.security.CryptoModuleDiagnosticDTO; +import com.eactive.eai.common.security.CryptoModuleManager; +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; + +/** + * CryptoModuleManageService 단위 테스트. + * + *

DB / 실 Spring 컨텍스트 없이 Mock으로 동작한다. CryptoModuleManager는 실제 인스턴스를 + * 사용하며(싱글턴 getInstance() 경유), HSM 및 DB 로더만 Mock 처리한다. + */ +class CryptoModuleManageServiceTest { + + 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; + + private final CryptoModuleManageService service = new CryptoModuleManageService(); + + @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() throws Exception { + reset(mockLoader); + when(mockLoader.findAll()).thenReturn(Collections.emptyList()); + reload(); + } + + // ========================================================================= + // 1. 진단정보 위임 — listAll / get + // ========================================================================= + + @Test + @DisplayName("1-1. listAll() — CryptoModuleManager.describeAll() 위임") + void testListAll_delegatesToManager() throws Exception { + CryptoModuleConfig config = buildStaticConfig("SVC_LIST", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + List result = service.listAll(); + + assertEquals(1, result.size()); + assertEquals("SVC_LIST", result.get(0).getCryptoName()); + } + + @Test + @DisplayName("1-2. get() — 미등록 cryptoName은 IllegalArgumentException 전파") + void testGet_unknownName_throwsException() { + assertThrows(IllegalArgumentException.class, () -> service.get("UNKNOWN_MODULE")); + } + + // ========================================================================= + // 2. 동적 키 캐시 목록 — listDynamicCacheKeys + // ========================================================================= + + @Test + @DisplayName("2-1. listDynamicCacheKeys() — DYNAMIC 암호화 테스트 후 캐시키 노출") + void testListDynamicCacheKeys_afterTestEncrypt_containsCacheKey() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("SVC_CACHE_KEYS", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + Map runtimeCtx = new HashMap<>(); + runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01"); + String plainB64 = Base64.getEncoder().encodeToString("hello".getBytes(StandardCharsets.UTF_8)); + + service.testEncrypt("SVC_CACHE_KEYS", runtimeCtx, plainB64, null); + + assertEquals(Collections.singletonList("SVC_CACHE_KEYS:clientKeyValue01"), service.listDynamicCacheKeys()); + } + + // ========================================================================= + // 3. 암복호화 테스트 — testEncrypt / testDecrypt + // ========================================================================= + + @Test + @DisplayName("3-1. STATIC — 암호화 후 복호화 라운드트립 (Base64)") + void testEncryptDecrypt_static_roundTrip() throws Exception { + CryptoModuleConfig config = buildStaticConfig("SVC_STATIC_RT", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + String plainText = "테스트 평문"; + String plainB64 = Base64.getEncoder().encodeToString(plainText.getBytes(StandardCharsets.UTF_8)); + + String cipherB64 = service.testEncrypt("SVC_STATIC_RT", null, plainB64, null); + assertNotNull(cipherB64); + assertNotEquals(plainB64, cipherB64); + + String decryptedB64 = service.testDecrypt("SVC_STATIC_RT", null, cipherB64, null); + assertEquals(plainText, new String(Base64.getDecoder().decode(decryptedB64), StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("3-2. DYNAMIC — runtimeContext 전달 시 암복호화 라운드트립 (Base64)") + void testEncryptDecrypt_dynamic_roundTrip() throws Exception { + CryptoModuleConfig config = buildDynamicConfig("SVC_DYNAMIC_RT", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + Map runtimeCtx = new HashMap<>(); + runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01"); + + String plainText = "DYNAMIC 키 테스트"; + String plainB64 = Base64.getEncoder().encodeToString(plainText.getBytes(StandardCharsets.UTF_8)); + + String cipherB64 = service.testEncrypt("SVC_DYNAMIC_RT", runtimeCtx, plainB64, null); + String decryptedB64 = service.testDecrypt("SVC_DYNAMIC_RT", runtimeCtx, cipherB64, null); + + assertEquals(plainText, new String(Base64.getDecoder().decode(decryptedB64), StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("3-3. testDecrypt() — 잘못된 Base64 입력은 예외 전파") + void testDecrypt_invalidBase64_throwsException() throws Exception { + CryptoModuleConfig config = buildStaticConfig("SVC_BAD_B64", "AES", "CBC", "PKCS5Padding"); + when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); + reload(); + + assertThrows(IllegalArgumentException.class, + () -> service.testDecrypt("SVC_BAD_B64", null, "not-valid-base64!!", null)); + } + + @Test + @DisplayName("3-4. testEncrypt() — 미등록 cryptoName은 예외 전파") + void testEncrypt_unknownName_throwsException() { + String plainB64 = Base64.getEncoder().encodeToString("x".getBytes(StandardCharsets.UTF_8)); + assertThrows(IllegalArgumentException.class, + () -> service.testEncrypt("UNKNOWN_MODULE", null, plainB64, null)); + } + + // ========================================================================= + // 헬퍼 + // ========================================================================= + + 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; + } + }; + } +}