feat: 암호화모듈 프레임워크 신규 구현
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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, "편의 메서드와 오프셋 메서드의 결과가 동일해야 한다");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<CryptoModuleManager> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> ctx1 = new HashMap<>();
|
||||
ctx1.put("X-Api-Enc-Key", "keyValueAAA00001");
|
||||
|
||||
Map<String, String> 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<CryptoModuleManager> 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<String, String> 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<String, String> encCtx = new HashMap<>();
|
||||
encCtx.put("X-Api-Enc-Key", "encryptKeyValue0");
|
||||
|
||||
Map<String, String> 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+192
@@ -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<String, String> params;
|
||||
private Map<String, String> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user