현재 개발 중인 서비스에서 배송 기능이 있어 고객의 배송지와 전화번호를 입력받고 있다. 전화번호는 문자 인증을 통해 인증된 번호만 DB에 저장되도록 구현했다. 그런데, 전화번호와 같은 민감한 개인정보를 그냥 DB에 평문으로 저장하면, 해킹이나 데이터 유출이 발생할 시 사용자의 전화번호가 그대로 노출될 수 있어 보안상 위험이 있었다.
이를 예방하기 위해, 데이터 유출 시에도 암호화 키가 없는 경우 전화번호를 원본 그대로 노출할 수 없도록 암호화해야겠다고 판단했다. 암호화 알고리즘에 RSA, AES, DES 등 여러가지가 있는데, 그중 AES 알고리즘을 선택했다.
AES 알고리즘 선택 이유
현재 개발 중인 서비스에서는 사용자의 전화번호를 암호화하여 저장하고, 복호화하여 배송 정보로 활용할 수 있으면 충분하다. 이 경우, 암호화된 정보를 타인이 복호화할 일도 없고, 암호화한 사람이 본인임을 증명(전자서명)할 필요도 없기 때문에 속도가 느린 비대칭키 방식이 아닌 대칭키 암호화 방식이 적합하다고 판단했다. 이에 따라 대칭키 암호화 방식 중 구현이 비교적 간단하고 강력한 보안을 제공하는 AES 알고리즘을 선택했다.
대칭키 vs 비대칭키 암호화 방식
대칭키 암호화: 암호화와 복호화에 동일한 키를 사용하는 암호화 방식이다. 하나의 비밀키를 공유하여 데이터를 암호화하고 복호화한다. 비대칭키 암호화 방식에 비해 속도가 약 1000배 빠르다.
비대칭키 암호화: 암호화 키와 복호화 키가 서로 다른 암호화 방식이다. 전자서명 방식에서는 개인만 아는 비밀키(private key)로 데이터를 암호화하고, 공개된 공개키(public key)로 복호화하여 신원을 확인할 수 있다. 반면, 기밀성 유지 방식에서는 공개키로 데이터를 암호화하고, 비밀키로 복호화하여 수신자만이 내용을 확인할 수 있도록 한다.
aes의 개념과 간단한 구현 코드를 보고 싶다면 아래 포스팅을 참고하자.
https://persi0815.tistory.com/78
[ AES 암호화 과정 ]
public String encryptPhoneNumberWithAES(String phoneNumber)
throws IllegalBlockSizeException, BadPaddingException, NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
// AES 암호화 키 생성
SecretKeySpec secretKey = generateAESKey();
// IV (Initialization Vector) 생성
IvParameterSpec iv = generateInitializationVector();
// AES 암호화에 사용할 Cipher 객체 설정
Cipher cipher = configureCipher(AES_PHONE_NUMBER_TRANSFORMATION, secretKey, iv);
// 전화번호를 AES로 암호화
byte[] encryptedBytes = cipher.doFinal(phoneNumber.getBytes());
// IV와 암호화된 전화번호를 Base64로 인코딩하여 결합하여 반환
return Base64.getEncoder().encodeToString(iv.getIV()) + ":" + Base64.getEncoder()
.encodeToString(encryptedBytes);
}
public static final String AES_PHONE_NUMBER_TRANSFORMATION = "AES/CBC/PKCS5Padding";
1. 암호화 키 생성
: 환경변수로 만들어 놓은 문자열 KEY 통해 2진수 128 비트 KEY 생성하기
@Value("${aes.phone-number}")
private String phoneNumberKey;
private SecretKeySpec generateAESKey() {
return new SecretKeySpec(phoneNumberKey.getBytes(), "AES");
}
phoneNumberKey 문자열을 바이트 배열로 변환
2. Initialization Vector 생성
: 암호화의 무작위성을 보장하기 위해 IV(초기화 벡터)를 생성
private IvParameterSpec generateInitializationVector() {
byte[] ivBytes = new byte[16];
SecureRandom random = new SecureRandom();
random.nextBytes(ivBytes);
return new IvParameterSpec(ivBytes);
}
암호화 과정에서 추가적인 무작위성을 부여하여 동일한 키와 평문으로도 암호문이 달라짐
특히 CBC(Cipher Block Chaining)와 같은 블록 암호 모드에서 필수적임!!
CBC(Cipher Block Chaining): 암호화 과정에서 각 블록이 연결(chain)되어 있기 때문에 동일한 평문 데이터라도 항상 다른 암호문이 생성됨
3. AES 암호화에 사용할 Cipher 객체 설정
: AES 암호화에 필요한 Cipher 객체를 configureCipher 메서드를 통해 설정. 이 때, 생성한 키와 IV를 설정하여 AES 암호화를 준비.
private Cipher configureCipher(String transformation, SecretKeySpec secretKey, IvParameterSpec iv)
throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
Cipher cipher = Cipher.getInstance(transformation);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv);
return cipher;
}
public static final String AES_PHONE_NUMBER_TRANSFORMATION = "AES/CBC/PKCS5Padding";
- AES: 사용 알고리즘 (대칭 키 암호화)
- CBC: 사용 모드 (Cipher Block Chaining)
- PKCS5Padding: 패딩 스킴 (데이터 길이를 블록 크기에 맞추기 위해 추가 데이터 삽입)
4. AES 암호화
: 생성했던 Ciper 객체의 doFinal 메소드를 이용하여 AES 암호화
byte[] encryptedBytes = cipher.doFinal(phoneNumber.getBytes());
* doFinal 메소드는 Cipher에 설정된 모드에 따라 입력 데이터를 암호화 또는 복호화하는 역할을 수행
즉, AES-128의 경우, AES 알고리즘은 4단계(SubBytes, ShiftRows, MixColumns, AddRoundKey)로 구성된 라운드를 10번 반복하는 것!
doFinal() 내부 로직
1. 암호화/복호화 모드에 따라 설정된 키와 IV를 사용하여 초기화 벡터와 키 스케줄을 준비
2. AES 라운드 진행: AES 알고리즘에 따라 각 라운드의 4단계가 지정된 횟수만큼 진행
3. 출력 생성: 마지막 라운드를 마친 후 최종 암호화된 데이터를 반환
5. IV와 함께 암호화된 전화번호 반환
return Base64.getEncoder().encodeToString(iv.getIV()) + ":" + Base64.getEncoder()
.encodeToString(encryptedBytes);
[ AES 복호화 과정 ]
public String decryptPhoneNumber(String encryptedPhoneNumber) throws Exception {
// AES 키 생성
SecretKeySpec secretKey = generateAESKey();
// 암호문에서 IV와 암호화된 전화번호를 추출
EncryptedData encryptedData = extractIVAndEncryptedPhoneNumber(encryptedPhoneNumber);
byte[] ivBytes = encryptedData.getIv();
byte[] encryptedPhoneNumberBytes = encryptedData.getEncryptedPhoneNumber();
// IV를 IvParameterSpec 객체로 생성하여 복호화에 사용
IvParameterSpec iv = new IvParameterSpec(ivBytes);
// 복호화에 사용할 Cipher 객체 설정 (AES/CBC 모드와 AES 키, IV 사용)
Cipher cipher = configureCipher(AES_PHONE_NUMBER_TRANSFORMATION, secretKey, iv);
// 암호화된 전화번호를 복호화하여 평문으로 변환
byte[] decryptedBytes = cipher.doFinal(encryptedPhoneNumberBytes);
// 복호화된 평문을 문자열로 반환
return new String(decryptedBytes);
}
1. 암호화 키 메서드 생성
: 암호화 때와 동일한 방식으로 AES 복호화에 사용할 비밀키(SecretKeySpec)를 생성
private SecretKeySpec generateAESKey() {
return new SecretKeySpec(phoneNumberKey.getBytes(), "AES");
}
2. 암호문에서 IV와 암호화된 전화번호 추출
: 암호문에서 IV와 암호화된 전화번호 바이트 배열을 분리하여 DTO 이용하여 가져옴
private EncryptedData extractIVAndEncryptedPhoneNumber(String encryptedPhoneNumber) {
String[] parts = encryptedPhoneNumber.split(":");
byte[] ivBytes = Base64.getDecoder().decode(parts[0]);
byte[] encryptedBytes = Base64.getDecoder().decode(parts[1]);
return new EncryptedData(ivBytes, encryptedBytes);
}
public class AesDto {
@AllArgsConstructor
public static class EncryptedData {
private byte[] iv;
private byte[] encryptedPhoneNumber;
public byte[] getIv() {
return iv;
}
public byte[] getEncryptedPhoneNumber() {
return encryptedPhoneNumber;
}
}
}
DTO를 이용하여 데이터를 가져오면, 여러 장점이 있지만,
첫째, 안전한 접근 제어가 가능해진다. DTO를 통해 외부 클래스에서 직접 데이터를 조작하지 않도록 하여, 안전하게 읽기 전용 데이터로 사용할 수 있다. 즉, getter만을 제공하거나 불변(immutable) 객체로 설정하면 데이터 무결성 유지가 가능하다.
둘째, 가독성이 향상된다. EncryptedData 객체를 사용하면 반환값이나 메서드 매개변수의 의미가 명확해져 가독성이 높아진다. 예를 들어, 반환값으로 byte[] 배열 두 개를 사용하는 대신 EncryptedData 객체를 반환하면 각 필드의 용도가 더 분명해진다.
3. IV 생성
: byte[] ivBytes 배열을 받아서 이를 사용하여 IvParameterSpec 객체 생성
4. IvParameterSpec 객체를 생성하여 복호화 시에 IV로 사용
: Cipher 객체를 복호화 모드(DECRYPT_MODE)로 초기화. 설정된 AES 키와 IV를 통해 복호화에 사용할 준비.
private Cipher configureCipher(String transformation, SecretKeySpec secretKey, IvParameterSpec iv)
throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
Cipher cipher = Cipher.getInstance(transformation);
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv);
return cipher;
}
5. 암호화된 전화번호 복호화
: Cipher의 doFinal 메서드를 호출하여 암호화된 전화번호를 복호화하고, 평문 바이트 배열을 반환받음
byte[] decryptedBytes = cipher.doFinal(encryptedPhoneNumberBytes);
결과물 확인
1. 배송지(전화번호) 입력
2. DB에서 저장된 전화번호 확인
3. 배송지 정보(전화번호) 조회
추후 공개가 될..? 것 같은 관련 PR
'Backend > Spring Boot' 카테고리의 다른 글
[Spring Data Jpa] 3가지 방법으로 N+1 문제 해결해보기 (Fetch Join, Batch Fetching, EntityGraph) (1) | 2025.01.16 |
---|---|
FCM 프로젝트 만들기 (Firebase 2024 최신버전) (0) | 2024.11.26 |
[Spring Boot] 로깅이란? 3가지 Logging 방식 소개 (0) | 2024.11.11 |
[Spring/Java] FCM을 통해 Push 알림 보내기 (0) | 2024.07.08 |
[Spring/Java] CORS란? 해결법은? (0) | 2024.07.07 |