이전 글에서는 시리우스 팀의 돌돌 프로젝트 NginX 세팅하면서 겪은 시행착오에 대해 기술하였습니다. 이번에는 돌돌 프로젝트의 메세지 작성 시 내용 암호화를 하면서 겪은 시행착오와 어떤식으로 암호화를 설정했는 지에 대해 글을 작성해보려고 합니다.
???: 암호화를 안하면 제 정보가 털렸을 시 위험하잖아요~~
네.정답입니다.⭕️
DB가 털렸을 시, 또한 관리자 또는 개발자가 DB에 접속해서 사용자들의 개인정보 또는 사용자가 작성한 메세지를 마음대로 본다면 사용자들은 서비스를 이용하기 싫으실겁니다. 또한 해당 서비스에 대한 신뢰도 떨어지죠.
암호화는 크게 2가지로 나누어져있습니다.
암호화는 가능 ⭕️ But 복호화는 불가능 ❌
이름에 나와있듯이 한쪽 방향(암호화)으로만 이루어진 암호화 기법입니다.
아래와 같은 기법들이 있습니다.
- 해시 함수 (Hash Functions): MD5, SHA-1, SHA-256, SHA-512 등등
- 암호화 해시 함수: bcrypt, scrypt, Argon2 등등
같은 입력에 대해 항상 같은 출력을 생성하지만, 출력으로부터 원본 데이터를 복원할 수 없습니다.
많은 개발자분들께서 회원가입 시 비밀번호를 암호화를 하여 DB에 적재를 하고 로그인 시 BCryptPasswordEncoder matches 메서드를 통해 옳바른 유저인지 검증을 할텐데요. (⚠️ PasswordEncoder의 구현체는 여러개있습니다. 그중 BCryptPasswordEncoder를 하나의 예시로 들었습니다.)
여기서 의문이 드실 수 있습니다. "단방향 암호화를 해놓았는데 어떻게 검증을 한거지?? 복호화가 안된다면서... 어케했지??😭"
저도 처음에는 "프론트에서 받은 아이디, 비밀번호를 받아 아이디를 통해 DB에서 조회 후, DB에 적재되어있던 비밀번호를 복호화해서 equals()를 통해 비교하나보다..." 라고 생각했습니다.
BUT....
실제 회원가입 과정에서 암호화를 한다면 아래와 같이 DB의 비밀번호가 저장됩니다.
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String hash = encoder.encode("myPassword");
System.out.println(hash);
// 출력: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
// │ │ │ │━━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━│
// │ │ │ └── Salt (22문자) └── Hash (31문자)
// │ │ └── Strength
// │ └── 버전
// └── 식별자
이중 유심히 봐야할 부분이 Salt 입니다.
간단하게 말해서 해시화 과정에서 평문에 추가되는 랜덤한 문자열을 추가합니다.
"소금을 뿌린다"는 의미로, 원본 데이터에 무작위 값을 추가한다는 뜻입니다.
public class SaltExtractionExample {
public static void demonstrateSaltExtraction() {
String bcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
// 1단계: $ 기준으로 분리
String[] parts = bcryptHash.split("\\$");
// parts[0] = "" (빈 문자열)
// parts[1] = "2a" (버전)
// parts[2] = "10" (strength)
// parts[3] = "N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// 2단계: Salt와 Hash 분리
String saltAndHashPart = parts[3];
String salt = saltAndHashPart.substring(0, 22); // 처음 22문자
String actualHash = saltAndHashPart.substring(22); // 나머지 31문자
System.out.println("전체 해시: " + bcryptHash);
System.out.println("Salt: " + salt);
System.out.println("실제 해시: " + actualHash);
// Salt를 포함한 전체 식별자 (재해시용)
String saltForHashing = "$2a$10$" + salt;
System.out.println("재해시용 Salt: " + saltForHashing);
}
}
matches() 내부에서 BCrypt.checkpw() 을 호출합니다. 해당 메서드가 위의 형식으로 구성되어있다고 생각하시면 됩니다.
<암호화>
1. 프론트에서 전달 받은 아이디를 바탕으로 DB에서 조회
2. 조회한 비밀번호에서 Salt 부분을 추출
3. 프론트에서 전달 받은 비밀번호 + 추출한 Salt를 이용해서 암호화
4. 암호화된 비밀번호와 DB의 비밀번호를 비교
암호화는 가능 ⭕️ AND 복호화 가능 ⭕️
이름에 나와있듯이 양쪽 방향(암호화, 복호화)로 이루어진 암호화 기법입니다.
아래와 같은 기법들이 있습니다.
- 대칭키 암호화: AES, DES, 3DES, Blowfish 등등
- 비대칭키 암호화: RSA, ECC, DSA 등등
암호화 할 때 이용한 고유 Key 를 이용해서 복호화를 합니다. 그래서 출력으로부터 원본 데이터를 복원 할 수 있습니다.
이번 블로그를 작성한 이유라고 생각 할 수 있습니다. 제가 양방향 암호화를 도입하면서 어떤 이슈를 맞닥뜨렸는 지 설명하겠습니다.
실제로 돌돌 서비스에서 메세지를 암호화 및 복호화를 하기 위해 Jasypt(Java Simplified Encryption)를 사용했습니다.
해당 라이브러리는 Java 개발자가 복잡한 암호화 로직을 간단한 API로 제공하여 개발자가 암호화에 대한 깊은 지식 없이도 안전하게 데이터를 암호화할 수 있습니다.
아래 코드가 JasyptConfig 클래스입니다.
package doldol_server.doldol.common.config;
import org.jasypt.encryption.pbe.PooledPBEStringEncryptor;
import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JasyptConfig {
@Value("${jasypt.encryptor.password}")
private String encryptKey;
@Bean(name = "jasyptEncryptor")
public SimpleStringPBEConfig encryptor() {
PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
SimpleStringPBEConfig config = new SimpleStringPBEConfig();
config.setPassword(encryptKey);
config.setStringOutputType("base64");
encryptor.setConfig(config);
return config;
}
}
기본적으로 Jasypt는 랜덤 Salt를 생성합니다.
해당 이슈로 인해사용자의 개인정보는 모두 암호화를 해야한다고 생각했어서 아이디, 이메일을 암호화했습니다.(사건의 발단...🔥 Dev 서버라서 다행입니다 😭)
제가 생각한 로그인 방식은 아래와 같습니다.
1. 프론트에서 아이디를 받는다.
2. 고정된 Salt와 서버에 저장되어있는 master key를 이용해서 아이디를 암호화한다.
3. 암호화한 값을 이용해서 DB의 Id 필드와 같은 User를 반환한다.
BUT....!
하하하하하하핳하하하하하하하하하하하하하하하하하하하하하하하하하하하핳
로그인이 안됩니다. 처음엔 랜덤으로 Salt를 생성하는 줄 몰랐습니다.
<암호화>
1. 프론트에서 아이디를 받는다.
2. 랜덤 Salt 생성 (8바이트)와 랜덤 IV 생성 (16바이트)
3. 마스터 패스워드 + Salt → PBKDF2 → 암호화 키 도출
4. 평문을 AES로 암호화 (도출된 키 + IV 사용)
5. Salt + IV + 암호화된 데이터 결합 → Base64 인코딩
아이디를 랜덤 Salt를 이용해서 암호화를 하니 로그인이 안되는 상황이 발생합니다. 그래서 고정 Salt를 사용을 했습니다.
하지만, 같은 비밀번호를 사용하는 사용자들의 해시가 동일하기에 보안적으로 취약합니다. 또한 공격자는 탈취한 데이터베이스의 모든 해시값과 비교해서 매칭되는 것이 있으면 즉시 비밀번호 발견 할 수 있습니다.
그럼 "프론트에서 암호화를 하고, 백엔드에서 고정 Salt를 이용하면 되지 않을까?? 🤔" 생각했습니다.
실제로 테스트를 해보진 않았지만, 프론트에서 암호화 및 복호화를 할 때 키의 노출 위험성 뿐 아니라 페이지의 로딩 속도와 같은 성능 이슈가 발생합니다. 또한 백엔드에서도 암호화 및 복호화의 빈도가 높으면 서버 CPU 사용률이 올라갑니다.
그래서 고정 Salt는 지양해야 합니다.
아이디를 암호화를 하지 않아도, 비밀번호가 암호화가 되어있기에 굳이 암호화를 할 필요가 없다고 생각합니다.
이메일도 서버가 사용시점에 유저 이메일을 알아야 하기에 여기서 이미 해싱은 탈락입니다. 또한 유저 이메일이 암호화해서 저장해야 할 민감정보가 아니라고 판단했습니다.
물론 사용자의 모든 정보를 암호화를 해야하는게 좋겠지만, 이를 통해 서버의 부하와 사용자의 이탈 등 추가적으로 비용이 발생합니다.
정보의 조합으로 개인이 식별될 수 있는 정보인지, 암호화해서 저장해야 할 민감정보인지를 생각해서 필요한것만 암호화를 하는게 중요한것 같습니다.⭐️
위 기준에 따라 메세지는 암호화를 할만큼 민감한 정보라고 판단이 들었습니다. 서로만 알 수 있는 비밀스러운 말을 담았을 수도 있고 자신의 말하지 못한걸 적었을 수 있습니다. 충분히 암호화 할 가치가 있고 이를 특정 기간이 지났을 때는 메세지를 보여줘야하기에 복호화도 필수입니다.
코드는 정말 간단합니다. 이전에 생성한 JasyptConfig 클래스에 설정 등록을 해놓았으니, 이를 이용하여 encryptor.decrypt() 코드를 이용하시면 됩니다.
private final StringEncryptor encryptor;
.
.
.
.
private MessageResponse decryptMessageContent(MessageResponse messageResponse) {
String decryptedContent = encryptor.decrypt(messageResponse.content());
return messageResponse.withDecryptedContent(decryptedContent);
}
"그런데 Salt가 랜덤이라면서요...🧐 복호화가 안될것 같은데, 어떻게 했습니까!!!!""
암호화 할 때 Salt가 랜덤으로 생성되어도 상관이 없습니다. 실제 동작은 아래와 같습니다.
<복호화>
1. 프론트에서 DB에서 조회 할 식별값을 받는다.
2. 해당 식별값을 통해 DB에 조회를 한다.
3. Base64 디코딩 → Salt (8바이트) + IV (16바이트) + 암호화된 데이터 분리
4. 마스터 패스워드 + 추출한 Salt → 암호화 키 도출 (암호화할 때와 동일)
5. 추출한 IV + 도출된 키로 AES 복호화 → 평문 데이터 획득
6. 복호화된 평문 데이터 반환
DB에서 저장되어있는 Salt를 그대로 사용하기에, 복호화가 가능한거죠. 하지만 Salt를 DB에 저장되어있는 값이 아닌, 랜덤으로 사용 할 시엔 오류가 발생합니다.
ㅋㅋㅋㅋ 암호화 트러블 슈팅,,, 진짜 한 번 꼬이면 다 갖다 버리고 싶어유..