오늘은 프로젝트 하면서 AES256 Util을 작성했던 내용을 정리해보려고 한다.
우리 프로젝트에는 익명 게시판이 있었는데, 프론트로 response를 보낼 때 사용자의 Id(DB상의 Id)가 노출되지 않도록 이를 암호화해서 보내기로 했다. 프론트에서 이후에 다른 요청을 보낼 때 이 암호화된 Id를 다시 request로 사용하므로, 백엔드 서버에서는 해당 Id를 복호화할 수 있어야 했다. 그래서 우리는 양방향 암호화를 선택했고, 암호화와 복호화 모두 백엔드 서버에서 이루어지므로 대칭키를 사용하는 AES-256 알고리즘을 사용하기로 했다.
또한, 우리는 익명 게시판에서 특정 사용자가 여러 개의 게시글을 작성했을 경우, 프론트단에서 동일한 사용자가 작성한 게시글임을 알 수 없도록 할 필요가 있었다. 마지막으로, 특정 한 게시글 내에서는 게시글 작성자와 댓글을 단 사용자들을 구별할 수 있어야 했다. 이를 구현하기 위해, salt값으로 게시글 Id를 넣어 게시글마다 사용자 Id가 다르게 암호화되도록 했다.
요구사항을 정리해 보면 다음과 같다.
- 사용자 Id를 암호화 및 복호화할 수 있어야 한다.
- 동일한 사용자가 작성한 게시글이더라도, 각 게시글에서 사용자 Id는 다르게 표시되어야 한다.
- 특정 게시글 내에서는 모든 사용자를 식별할 수 있어야 한다.
그럼 코드를 보기 전에, AES256 암호화가 뭔지, 어떤 방식으로 동작하는지 알아보자.
1. AES(Advanced Encryption Standard) 암호화란?
- DES를 대체한 암호 알고리즘으로, 미국의 NIST 기관에서 고안한 알고리즘이다.
- 암호화와 복호화 과정에서 동일한 키를 사용하는 대칭키 알고리즘이다.
- 대입 치환 SPN(substitution-Permutation Network)을 사용하는 블록암호 방식이다.
- 키 값의 길이에 따라 AES128, AES192, AES256으로 나뉜다.
- 128bit의 고정된 블럭 단위로 암호화를 수행한다. (Block Cipher Mode - EBC, CBC 등)
1-1. CBC(Cipher Block Chaining) Mode
여기서 우리는 CBC 모드로 AES256 Util을 구현할 것이기 때문에
CBC 방식의 동작 원리를 알아둘 필요가 있다.
CBC 모드는 앞의 암호문이 뒷 블럭 암호화에 사용되는 Chaining 방식이다.
암호화 첫 부분에는 이전 암호화 블럭이 없으므로 IV라는 값이 사용된다.
이때 IV는 제 2의 키가 될 수 있다! IV값이 다르면 암호화할 평문과 키가 동일하더라도 다른 암호문이 생성된다.
AES는 128비트 단위로 암호화를 하기 때문에, IV도 동일하게 128비트(16바이트) 크기여야 한다.
1-2. PKCS5 Padding
위에서 AES 암호화는 128bit 크기의 블록 단위로 암호화를 진행한다고 했다.
그런데 만약, 암호화하려는 평문이 128bit 단위로 완벽하게 나누어 떨어지지 않는다면 어떻게 해야할까?
이런 경우를 대비해 블록 암호화에서는 패딩 기법이 사용된다.
즉, 128bit 보다 작은 블록이 생길 경우 부족한 부분을 특정 값으로 채워주는 것이다.
대표적으로 PKCS#5(암호 블럭 사이즈 - 8bytes), PKCS#7(암호 블럭 사이즈 - 16bytes) 방식이 있는데,
Java에서는 두 개를 구분하지 않고 PKCS5 Padding이라고 입력한다. (legacy인 듯 하다)
그럼 이제 코드를 보자!
2. AES256 Util 코드
package kr.co.wingle.common.util;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.binary.Hex;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import kr.co.wingle.common.constants.ErrorCode;
import kr.co.wingle.common.exception.InternalServerErrorException;
@Component
public class AES256Util {
private static String KEY;
private static byte[] SALT;
private static String IV;
@Value("${aes256.key}")
public void setKEY(String KEY) {
AES256Util.KEY = KEY;
}
@Value("${aes256.salt}")
public void setSALT(String SALT) throws DecoderException {
AES256Util.SALT = Hex.decodeHex(SALT.toCharArray());
}
@Value("${aes256.iv}")
public void setIV(String IV) {
AES256Util.IV = IV;
}
public static String encrypt(String str) {
try {
SecretKey key = generateKey(KEY);
byte[] encrypted = doFinal(Cipher.ENCRYPT_MODE, key, IV, str.getBytes("UTF-8"));
return encodeHex(encrypted);
} catch (Exception e) {
throw new InternalServerErrorException(ErrorCode.ENCRYPT_FAIL);
}
}
public static String encrypt(String str, String salt) {
try {
SecretKey key = generateKey(KEY, salt);
byte[] encrypted = doFinal(Cipher.ENCRYPT_MODE, key, IV, str.getBytes("UTF-8"));
return encodeHex(encrypted);
} catch (Exception e) {
throw new InternalServerErrorException(ErrorCode.ENCRYPT_FAIL);
}
}
public static String decrypt(String str) {
try {
SecretKey key = generateKey(KEY);
byte[] decrypted = doFinal(Cipher.DECRYPT_MODE, key, IV, decodeBase64(str));
return new String(decrypted, "UTF-8");
} catch (Exception e) {
throw new InternalServerErrorException(ErrorCode.DECRYPT_FAIL);
}
}
public static String decrypt(String str, String salt) {
try {
SecretKey key = generateKey(KEY, salt);
byte[] decrypted = doFinal(Cipher.DECRYPT_MODE, key, IV, decodeBase64(str));
return new String(decrypted, "UTF-8");
} catch (Exception e) {
throw new InternalServerErrorException(ErrorCode.DECRYPT_FAIL);
}
}
private static byte[] doFinal(int encryptMode, SecretKey key, String iv, byte[] bytes) throws Exception {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(encryptMode, key, new IvParameterSpec(decodeHex(iv)));
return cipher.doFinal(bytes);
}
private static SecretKey generateKey(String passPhrase) throws Exception {
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
// generate key with salt
PBEKeySpec keySpec = new PBEKeySpec(passPhrase.toCharArray(), SALT, 3000, 256);
SecretKey key = new SecretKeySpec(factory.generateSecret(keySpec).getEncoded(), "AES");
return key;
}
private static SecretKey generateKey(String passPhrase, String salt) throws Exception {
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
// generate custom salt
PBEKeySpec saltSpec = new PBEKeySpec(salt.toCharArray(), SALT, 3000, 128);
SecretKey saltKey = new SecretKeySpec(factory.generateSecret(saltSpec).getEncoded(), "AES");
// generate key with custom salt
PBEKeySpec keySpec = new PBEKeySpec(passPhrase.toCharArray(), saltKey.toString().getBytes(), 3000, 256);
SecretKey key = new SecretKeySpec(factory.generateSecret(keySpec).getEncoded(), "AES");
return key;
}
private static String encodeHex(byte[] bytes) {
return Hex.encodeHexString(bytes);
}
private static byte[] decodeHex(String str) throws Exception {
return Hex.decodeHex(str.toCharArray());
}
private static byte[] decodeBase64(String str) {
byte[] decodeByte = Base64.decodeBase64(str);
return Base64.decodeBase64(decodeByte);
}
}
먼저 암복호화를 위한 KEY, 디폴트 SALT, IV 값을 application.yml 파일에서 가져온다.
- KEY: KEYpassword
- SALT: AES256 기준 16진수 64자리
- IV: AES256 기준 16진수 32자리
그럼 암호화 과정 먼저 살펴보자.
암호화는 기본 encrypt 함수와 salt값을 매개변수로 함께 받는 encrypt 함수가 있다. 전자만 살펴보자.
- encrypt("plain text")
- generateKey(KEY)
- PBEKeySpec으로 SALT값과 함께 password(=위에 설정한 KEY) 기반의 암호화 키를 생성
- SecretKeySpec으로 AES 알고리즘을 적용한 최종 키 생성
- AES/CBC/PKCS5Padding 포맷의 Cipher 인스턴스를 위에서 생성한 값들로 초기화한 후, "plain text"를 암호화
- 마지막으로 Hex로 인코딩후 암호화된 텍스트 반환
salt값을 매개변수로 받는 encrypt 함수의 경우에는 여기서 3번에 추가 작업이 들어간다.
salt값이 16진수 64자리 값이어야 하는데, 매개변수에 어떤 값이 들어있을지 모르니
이를 최종 키를 생성하는 것처럼 한 번 감싸서 사용했다.
복호화의 경우에도 비슷하게 최종 키를 생성하고, cipher를 decrypt 모드로 설정해 복호화하면 된다.
이 코드를 구현할 때, 모든 과정을 완벽하게 이해하고 짠 것이 아니라서 오류나 의문점이 있을 수 있다.
수정이 필요한 부분이 있다면 댓글로 편하게 지적해주길 바란다!!
🔗 참고자료
AES-256 암호화
velog.io
[JAVA] 자바 AES 암호화 하기 (AES-128, AES-192, AES-256)
목차 0. AES 암호화 들어가기 전에 0.1. AES(Advanced Encryption Standard) 암호화란? - DES의 안전성에 대해 여러 가지 공격 방법들이 발표되며 미국의 NIST 기관에서 고안한 암호 알고리즘입니다. - AES 암호
veneas.tistory.com
AES 256 CBC + PBKDF2
막연하게 양방향 암호화 하면 당연스레 AES를 떠올리고, 제대로 모른 채로 사용했다.이제부터라도 조금은 알고 써야겠다는 생각이 들어서 살짝 정리해봤다. 양방향/단방향 암호화양방향 암호화
perfectacle.github.io
Java Cipher - 알고리즘, 운용 모드, 패딩의 이해
자바에서는 대칭키 알고리즘을 사용하여 데이터를 암호화/복호화할 때 javax.crypto.Cipher 클래스를 사용한다. 이 클래스의 인스턴스는 정적 메서드인 Cipher.getInstance()를 호출하여 가져올 수 있는데,
happinessoncode.com