Files
elink-online-common/src/test/java/com/eactive/eai/common/security/AESCryptoModuleExtensionTest.java
T
curry772 bf1135f9ad 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>
2026-05-06 16:49:33 +09:00

400 lines
18 KiB
Java

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, "편의 메서드와 오프셋 메서드의 결과가 동일해야 한다");
}
}