[해싱, 암호화] 데이터를 암호화하는 방법
JSP 프로젝트가 마무리되어간다. 이전에 git 사용방법을 올렸던 글이 있었는데, 해당 내용을 토대로 팀원들에게 git 사용방법을 알려주었다. 구글드라이브에 각자 작업한 파일을 업로드해서 수동으로 병합하는 것보다 git을 사용하는 것이 conflict 지점이 어딘 지 알 수 있어서 병합하는데 있어 훨씬 효율적이었고, 팀원들도 git 사용법을 알게되어 만족스러운 얼굴들을 하고 있어 기분이 좋았다. 이번 프로젝트를 통해 협업툴 사용하는 숙련도가 늘어난 것 같아서 좋은 경험을 한 것 같다.
이번 글은 JSP 프로젝트를 하면서 아쉬운 부분이 있어 작성하는 글이다. 바로 데이터를 암호화하는 방법이다. 프로젝트를 하면서 회원가입, 로그인 하는 부분을 구현하는 파트를 맡았는데, 구현은 성공적으로 했지만 뭔가 부족한 느낌이 들었다. 사용자가 회원가입이나 로그인 할 때 작성한 데이터값을 서버에 그대로 넘겨주고, 서버에서도 DB에서 데이터를 불러와서 아무런 암호화 작업없이 클라이언트에 응답데이터를 보내준 다는 것이 영 찜찜했다. 실무에서는 절대 이런식으로 작업하지 않을 것 같다는 생각이 들어서 암호화와 관련하여 구글링을 열심히 해봤다.
여러가지 암호화 관련 글을 보았는데, 그 중에
https://st-lab.tistory.com/100
패스워드의 암호화와 저장 - Hash(해시)와 Salt(솔트)
'보안은 그 어느 시스템의 정보보다 가장 중요하며 가장 안전해야 하는 것이다' 필자가 "프로그래머로써 가장 중요하게 생각해야 할 것 하나만 뽑는다면?" 이라는 질문이 들어온다면 위와 같이
st-lab.tistory.com
여기에 있는 글이 데이터를 암호화해야하는 이유와 개념들을 보기쉽게 정리해 놓은 글인 것 같아서 이 글을 여러 번 읽어보았다. 어딘가 익숙한 레이아웃의 블로그였는데, JAVA로 백준 문제풀이를 올리시는 분이 쓴 글이였다. 역시 입문자들을 대상으로 보기 쉽게 글을 작성하시는 분이라서 그런지 이해가 잘 되었다. 위 글을 읽고 중요하게 생각했던 개념들을 정리하려 한다.
1. 해싱과 암호화
- 해싱과 암호화의 차이는 '방향성'에 있다.
- 단방향은 한 방향으로만 데이터를 보내는 것을 의미하는데, 양방향으로 데이터를 주고받는 것이 아니다. 따라서 복호화가 불가능하다. 이를 '해싱'이라고 부른다.
- '암호화'는 양방향이다. 데이터를 주고받는 것이 가능하며, 주고받기 위해서는 암호를 복호화해야한다.
2. 단방향 해시 함수와 한계점
- 단방향 해시 함수는 어떤 수학적 연산에 의해 원본 데이터를 매핑시켜 완전히 다른 암호화된 데이터로 변환시키는 것을 의미한다. 암호화된 데이터를 digest라고 한다.
- 예를들어, 123456을 해시 함수를 통해 digest하면 fswefwgdf32a3xzz0와 같은 해시값이 나오게 되는데, 이 값을 DB에 저장하게 된다. 만약 해커가 DB를 들여다 보게 되었을 때, 비밀번호가 아닌 해시 함수를 보기 때문에 일단 직관적으로 비밀번호가 보이지 않게 된다.
- 단방향 해시 함수는 SHA, MD, HAS, WHIRLOPOOL 등과 같은 함수들이 있는데, SHA-256 함수가 대표적으로 많이 쓰이는 함수이다.
하지만 데이터를 단순히 해시 함수를 통해 해싱했다고해서 해커로부터 안전한 것은 아니다.
왜냐하면 같은 값을 입력하고 해시함수를 돌리면 똑같은 값을 리턴하기 때문이다. 동일한 값에 동일한 해시값이 나오기 때문에 해커들은 이 점을 이용하여 여러 값들을 해시함수에 대입해보고, 일치하는 해시값이 있으면 해킹할 수 있게 되는 것이다.
해커들이 해시함수에 값을 대입해보고 얻은 해시값들을 리스트로 모아놓은 digest의 테이블을 레인보우 테이블이라고 한다. 이미 구글링에 sha-516 rainbow table만 쳐도 흔하게 어떤 값이 나오는 지 찾을 수 있다.
또한 해시 함수를 사용하더라도 원문의 digest는 금방 얻어지게 되는데, 우리가 digest를 통해 해시값을 빠르게 얻을 수 있듯, 해커들 또한 똑같이 빠르게 값을 얻을 수 있기 때문에 문제가 된다. 보통 사용자가 비밀번호를 입력할 때는 기억하기 쉽게 상징성이 있는 비밀번호를 사용하는데, 해커가 이를 이용하여 브루트포스로 비밀번호값을 얻어낸다면 해킹은 시간문제일 것이다.
3. 그렇다면 어떻게 사용해야될까?
방법은 두 가지가 있는데, 두 가지를 모두 사용하는 것을 권장한다.
- 키 스트레칭(Key Stretching) : 비밀번호를 123456으로 지정하여 SHA-256에 digest를 하면 해시값이 나올 것이다. 나온 해시값 그대로 여러 번 digest를 한다. digest가 여러 번 되었기 때문에 123456과 완전히 다른 해시함수가 되어있을 것이다. 얼마나 돌려야 원래의 비밀번호 데이터를 찾을 수 있는 지는 소스코드를 보아야만 알 수 있을 것이다.
보통 해커가 브루트포스를 통해 데이터를 알아내기 위해서 1초에 10억번의 연산을 수행한다고 가정해보았을 때, 키 스트레칭을 하는 횟수가 많아질 수록 필요한 연산량은 더 많아질 것이다. 물론 사용자가 로그인할 때 느리다는 경험이 들지 않는 정도로 키 스트레칭 횟수를 한정해야겠지만, 브루트포스를 사용하는 해커 입장에서는 꽤나 치명적이라고 볼 수 있겠다.
- 솔트(Salt) : 키 스트레칭은 브루트포스로 해킹을 어느정도 방지하는 데 도움이 되는 방법이지만, 해커가 소스코드를 해킹해서 읽는다던지, 비밀번호의 상징성과 관련된 부분만 위주로 브루트포스를 했다고 했을 때 해킹될 가능성이 남아있게된다. 이 때는 솔트(Salt)를 써야한다.
솔트(Salt)는 사용자가 입력한 데이터에 랜덤으로 생성한 문자열을 덧붙여서 사용하는 것을 말한다. 말 그대로 데이터에 소금을 치는 것이다. 위의 예시를 다시 들어보자면, 123456이라는 비밀번호를 사용자가 입력했을 때, 랜덤으로 salt를 생성하여 나온 값인 b168724fwe4s를 123456에 덧붙인 다음 키 스트레칭을 진행하는 것이다.
솔트를 사용하는 목적은 해커가 레인보우 테이블을 사용하는 것을 막기 위함이다. 솔트값을 얻기위해 레인보우 테이블을 생성하여 만들기 위해서는 큰 데이터를 필요로 하기때문에 레인보우 테이블 생성을 방지할 수 있다.
정리한 글과 블로그에서 본 예시코드를 토대로 현재 진행중인 프로젝트에 적용시켜보았다.
현재 진행중인 프로젝트는 마켓컬리 클론코딩 프로젝트이다. 전체적인 사이트 구조는 클론을 하되, 기능적인 부분은 수업에서 배운 내용을 토대로 구현해보는 것을 목표로 잡고 진행중이다. 먼저 입력한 값을 hashing하고 랜덤으로 생성된 salt값을 얻어오는 등 데이터를 암호화하는 KurlySecure 클래스를 하나 만들었다.
KurlySecure.java
public class KurlySecure {
private static final int SALT_SIZE = 16;
// 입력받은 비밀번호를 해싱하는 메서드
public static String hashing(byte[] password, String salt) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
// key-stretching: 10000번 실행
for(int i=0; i<10000; i++) {
String temp = byteToString(password) + salt;
md.update(temp.getBytes());
password = md.digest();
}
return byteToString(password);
}
// byte - String 변환 메서드
private static String byteToString(byte[] temp) {
StringBuilder sb = new StringBuilder();
// byte를 string으로 변환한다.(16진수)
for(byte a : temp) {
sb.append(String.format("%02x", a));
}
return sb.toString();
}
// SecureRandom 객체를 통해 salt 값을 가져오는 메서드
public static String getSalt() {
SecureRandom sr = new SecureRandom();
byte[] salt = new byte[SALT_SIZE];
// nextBytes(): SecureRandom에서 난수를 생성하는 메서드,
// 파라미터에는 생성된 난수를 담을 byte 배열을 입력한다.
sr.nextBytes(salt);
return byteToString(salt);
}
}
hashing, byteToString, getSalt 3개의 메서드로 이루어져있으며, 각 메서드의 역할은 다음과 같다.
- hashing() : 사용자가 입력한 password, 사용자가 입력한 ID를 토대로 DB에서 가져온 salt를 합쳐서 해싱을 하는 함수이다. key stretching은 10000번을 수행하도록 했으며, 리턴값은 해싱한 비밀번호를 String으로 변환한 값이다.
- byteToString() : 말 그대로 byte 값을 String으로 변환하는 메서드이다. 파라미터값이 byte 배열이기 때문에 StringBuilder로 append하여 하나의 String 값을 리턴하도록 하였다.
- getSalt() : SecureRandom 객체를 사용하여 난수를 byte타입으로 생성한 후, String으로 변환하여 리턴한다. 회원가입 시에 사용하는 메서드이다.
코드를 작성하면서 궁금한 점이 하나 생겼다. 왜 사용자로부터 입력받은 비밀번호를 byte로 변환해서 사용해야하는 걸까? 기본적으로 데이터는 이진 데이터로 전달되는데, 이진 데이터 그대로 둘 경우에는 시스템에 따라서 특정한 값이 제어문자로 표현될 수 있기 때문이다. 여기서 제어문자는 (이스케이프, ESC, \e [GCC only], ^[).과 같이 키보드의 값을 문자로서 표현하기 위해 사용하는 것을 말한다. 따라서 byte[] 배열로 데이터를 저장하여 이 문제를 해결하였다.
UserRegistAction.java
public class UserRegistAction implements Action {
@Override
public ActionForward execute(HttpServletRequest request, HttpServletResponse response) throws IOException {
...
// salt 생성하기
String salt = KurlySecure.getSalt();
byte[] password = request.getParameter("reg_pw").getBytes();
String hashedPw = null;
try {
hashedPw = KurlySecure.hashing(password, salt);
System.out.println("salt = " + salt);
System.out.println("hashedPw = " + hashedPw);
} catch (NoSuchAlgorithmException e) {
System.out.println("[UserRegistAction Error] : KurlySecure.hashing Exception");
e.printStackTrace();
}
...
}
}
회원가입 버튼을 눌렀을 때 실행될 Action클래스이다. KurlySecure 객체를 통해 새로 생성된 salt 값을 가져오고, 사용자로부터 입력받은 비밀번호를 byte 배열로 변환한 다음 hashing 메서드를 사용하였다.
DB에는 해싱된 비밀번호와 salt 값이 각각 다른 컬럼에 들어가게 된다.
UserLoginAction.java
public class UserLoginAction implements Action {
@Override
public ActionForward execute(HttpServletRequest request, HttpServletResponse response) throws IOException {
String id = request.getParameter("id");
String pw = request.getParameter("pw");
String salt = saltCheck(id);
// salt가 null일 경우에는 아이디가 일치하지 않으므로 redirect
if(salt == null) {
ActionForward forward = new ActionForward();
forward.setRedirect(true);
forward.setPath(request.getContextPath() + "/user/user_login.jsp?notFound=true");
return forward;
}
// 입력받은 비밀번호를 hashing하기
String hashedPw = "";
try {
hashedPw = KurlySecure.hashing(pw.getBytes(), salt);
System.out.println("입력받은 ID = " + request.getParameter("id"));
System.out.println("가져온 salt = " + salt);
System.out.println("hashedPw = " + hashedPw);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
LoginDTO dto = new LoginDTO(id, hashedPw);
// 아이디, salt를 받아오는 DTO 객체
String username = UserDAO.getInstance().loginCheck(dto);
ActionForward forward = new ActionForward();
if(username != null) {
HttpSession session = request.getSession();
session.setAttribute("user_id", request.getParameter("id"));
session.setAttribute("user_name", username);
forward.setRedirect(true);
forward.setPath(request.getContextPath() + "/main.jsp");
} else {
forward.setRedirect(true);
forward.setPath(request.getContextPath() + "/user/user_login.jsp?notFound=true");
}
return forward;
}
private String saltCheck(String id) {
return UserDAO.getInstance().getUserSalt(id);
}
}
이번엔 로그인 Action 객체를 살펴보자, 로직이 실행되는 순서는 아래와 같다.
1. 사용자가 입력한 id 값과 일치하는 Salt 값을 DB에서 가져온다.
> 입력한 ID와 일치하는 Salt가 없을 경우 일치하는 정보가 없다는 메시지가 있는 login페이지로 redirect
2. 입력받은 pw와 salt를 합치고 hashing 메서드 수행
3. hashing한 pw와 입력받은 id가 일치할 경우 DB에서 사용자의 이름을 받아온다.
4. 입력한 id와 user_name을 session에 저장한다.
실행 결과
삽질할 줄 알았는데 한 번에 실행이 되서 많이 놀랐다. 콘솔에 출력해서 내가 실행한 값이 맞는지 확인해보고 제대로 실행되는 것을 확인했다. 비밀번호 값을 그대로 DB에 전송하는 것이 아니라 hashing한 값을 전송하니 보안이 조금이나마 되어있는 것 같아서 기분이 좋다.
물론 지금 작성한 코드는 가장 간단한 암호화 기법이기 때문에 전체적인 흐름이 어떻게 되는 지 이해하고, 데이터를 암호화하는 것이 어떤 개념인지 아는 것에 의의를 두려고한다. 나중에는 bouncycastle 이라는 라이브러리를 사용하여 암호화를 한다는데, 이것도 공부해볼 생각이다.
시간이 된다면 SSL/TLS, RSA도 공부해보고 싶다. 공개키 암호화 알고리즘인데, 데이터를 암호화하는 방법을 보다 깊게 알 수 있을 것 같다는 생각이 든다.