4 분 소요

“이돈이면” 프로젝트 진행 중 일정에 쫓겨 기능 구현을 우선적으로 진행하다 보니

회원가입 기능을 구현할 때 사용자가 입력한 비밀번호를 그대로 데이터베이스에 저장하는 방식으로 처리했다.

그러나 실제 서비스를 사용자들에게 배포하기로 한 시점에서 개인정보처리방침에 따라 사용자의 비밀번호를 암호화해야했다.

이에 프로젝트에서 암호화를 적용하면서 공부한 내용과 고민을 기록하고자 한다.


클라이언트와 서버 통신간 암호화(HTTPS)

우선, 클라이언트에서 서버로 넘어오는 시점을 생각해보았는데

이 시점에는 https를 적용하면 사용자가 입력한 데이터는 암호화되어 전송되기 때문에 클라이언트와 서버 통신 중 제 3자가 데이터를 볼 수 없도록 보호할 수 있었다.

HTTPS 암호화 과정

암호화 과정을 간단하게 요약하면

  1. 클라이언트가 서버에 접속을 요청하면 서버가 공개키를 보내준다.
  2. 서버의 공개키를 가지고 클라이언트가 자신의 대칭키를 암호화해서 서버에게 전송한다.
  3. 전송받은 암호화된 대칭키를 서버의 개인키로 복호화하여 대칭키를 얻는다.

이후, 클라이언트의 대칭키를 가지고 통신한다.

여기서 간단하게 서버가 공개키를 보내준다고 했지만, 공개키만으로는 클라이언트 입장에서 서버를 신뢰할 수 없다. (중간에 해커가 끼어들어 서버의 공개키 대신 자신의 공개 키를 주는 경우)
이를 보완하기 위해 클라이언트가 서버를 신뢰할 수 있도록 둘 사이에 CA(인증기관)가 개입해서 클라이언트와 서버가 상호 신뢰할 수 있게 해준다.

이 과정도 간단하게 요약하면 다음과 같다.

  1. 서버가 CA에게 공개키와 기타 정보(도메인등)를 제공
  2. CA에서 서버의 신원을 확인하고 서버에 대한 인증서를 발급

인증서에는 서버의 공개키와 신원정보가 포함되어 있기때문에 이 인증서로 클라이언트와 서버가 안전한 통신을 할 수 있게된다.

HTTPS 적용방법

이제 위의 암호화 과정을 직접 적용해보자.

  1. 가비아에서 도메인 발급

  2. 우분투 서버에 nginx 설치

    1. sudo apt-get install nginx
    2. /etc/nginx/conf.d 경로로 들어가서 .conf 파일 하나 만들기 (파일 이름 상관 없음)
    server {
        listen 80;
        server_name {발급받은 도메인 주소};
       
        location / {
           proxy_pass <http://127.0.0.1:8080>;
        }
    }
    

    입력해주기.

    → 8080 포트를 붙이지 않아도 알아서 붙여줌

  3. 인증서 발급 받기 (무료)

    1. https://certbot.eff.org/instructions?ws=nginx&os=ubuntufocal 들어가면 기본적인 설치방법이 나온다

    2. 아래 코드 입력해서 서버에 설치

      // 기존 certbot 삭제
      sudo apt-get remove certbot
            
      // 최신 certbot 설치
      sudo snap install --classic certbot
            
      // 명령이 실행되는지 잘 설치 됐는지 확인
      sudo ln -s /snap/bin/certbot /usr/bin/certbot
            
      // 인증서 발급
      sudo certbot certonly --nginx
            
      그 후에 발급받고 싶은 도메인 설정 (엔터 치면 모두다 발급)
      
    3. 인증서의 위치는

      /etc/letsencrypt/live/사이트명 에 있음

    4. 인증서 등록을 다음 위치에에 해줘야함

      sudo vi /etc/nginx/conf.d/아까만든.conf로 들어가서 다음과 같이 변경

      server {
          listen 80;
          server_name {발급받은 도메인 주소};
            
          location / {
             proxy_pass <http://127.0.0.1:8080>;
          }
            
        listen [::]:443 ssl ipv6only=on;
        listen 443 ssl;
        ssl_certificate /etc/letsencrypt/live/{발급받은 도메인 주소}/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/{발급받은 도메인 주소}/privkey.pem;
        include /etc/letsencrypt/options-ssl-nginx.conf;
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
      }
      
    5. 설정을 마쳤다면 sudo service nginx restart 명령어로 nginx 재시작

    https://{발급받은 도메인 주소} 들어가서 잘 적용됐는지 확인하면 된다.


서버에서 데이터베이스간 암호화

이제 클라이언트에서 받아온 비밀번호를 데이터베이스를 암호화해서 저장해야하는데

프로젝트에서 사용하는 Spring Boot, JPA, Mysql 에서 암호화를 어떻게 적용할지는 다음과 같다.

  1. 자바에서 암호화를 적용해 데이터베이스에 저장
  2. 스프링 시큐리티를 적용해 데이터베이스에 저장
  3. Mysql에서 제공하는 암호화 사용

1번을 우선으로 생각했고, 2번의 경우 스프링 시큐리티에서 암호화가 아닌 다른 오류가 발생하면 시간이 많이 걸리것 같아 제외했다.
3번의 경우는 데이터베이스에서 저장하기 때문에 JPA를 사용하는 프로젝트에서 데이터 불일치가 일어날것이라고 판단하고 제외했다.

위와 같은 이유를 가지고 자바에서 암호화를 적용해 데이터베이스에 저장하기로 결정했다.

암호화를 할때 보통 해시함수를 이용하는데, 해시함수는 단방향이기 때문에 해커가 데이터베이스에 저장된 비밀번호를 알게되더라도
이를 가지고 비밀번호 평문을 알아낼 수 없기 때문이다.

해시 함수를 가지고 암호화 하기는 어렵지 않다.

MessageDigest hashFunction = MessageDigest.getInstance(ALGORITHM_NAME);

public boolean matches(final String password, final String encodedPassword) {
    return encodedPassword.equals(encrypt(password));
}

public String encrypt(String password) {
	String encodedPassowrd = hashFunction.digest(password.getBytes()).toString();
	return encodedPassowrd
}

위에 코드에서 처럼 특정 해시알고리즘으로 암호화해주고 로그인 시, 해당 유저가 입력한 비밀번호를 다시 암호화해서 데이터베이스에 저장된 비밀번호와 같은지 확인해주면 된다.

하지만, 이렇게 해싱만 해서 DB에 저장하게되면 레인보우 테이블 공격을 받을 수 있다.

또한, 같은 비밀번호를 사용하는 사용자는 같은 해쉬 값을 갖기 때문에 위험하다.

  • 레인보우 테이블?

    여러 암호화 알고리즘을 통해 사람들이 자주 사용하는 비밀번호를 해쉬 값으로 저장해둔 테이블.

    예시) SHA-256를 사용한 레인보우 테이블

    평문(raw password) 해쉬 값
    password123 ajknwoijoi12ndkjn
    qwer1234 asdffasdf

    서비스의 DB가 털리거나 유출되었을 때, 레인보우 테이블의 해쉬 값을 서비스의 DB 테이블과 비교해서 평문을 찾아내는 방법.

이 방법을 보완하기 위해비밀번호 평문과 Salt(이하 소금)를 조합해서 해싱하는 방법을 사용한다.

그렇다면 소금을 어떻게 만들지 생각해보았을 때, 두가지 방법이 떠올랐다.

  1. 랜덤하게 만든다.
  2. 특정 정보를 조합해서 만든다.

1번의 경우 랜덤하게 만들면 서버 입장에서도 소금을 모르기 때문에 데이터 베이스에 저장해야한다.

이렇게 되면 데이터베이스가 유출되었을 때, 소금도 알 수 있는데 해킹할 수 있는거 아닌가?
→ 소금의 목적 자체가 해커가 만들어놓은 레인보우 테이블을 사용하지 못하게 하는것이므로 목적을 달성한 것이다.

하지만, 특정 인물을 겨냥하고 해킹을 한다면 충분히 해킹할 수 있다고 생각했고
추가로 데이터베이스에 저장하면 소금을 읽어오는 쿼리가 늘어나기 때문에 특정 정보를 조합해서 만들기로 결정했다.

특정 정보를 조합해서 만드는 방법을 생각했을 때, 가장 간단한 방법은 회원의 바뀌지 않는 데이터를 소금으로 사용하는 것이다.

예를 들어, 이메일을 소금으로 사용하면 다음과 같다.

MessageDigest hashFunction = MessageDigest.getInstance(ALGORITHM_NAME);

public boolean matches(String password, String email, String encodedPassword) {
    return encodedPassword.equals(encrypt(password, email));
}

public String encrypt(String password, String email) {
	String mixture = password + email;
	String encodedPassowrd = hashFunction.digest(mixture.getBytes()).toString();
	return encodedPassowrd
}

위 방법은 같은 비밀번호를 가진 여러 회원이 다른 해시 값을 가진다는 점에서 괜찮은 방법이다.
하지만, 이 자체도 이메일이라는 소금을 데이터베이스에 저장하기 때문에 충분이 유추가능하다고 생각했다.

그래서 생각해낸 방법은 소금과 비밀번호를 해시한 암호문 앞부분에 소금을 만들기 위한 정보를 넣는 것이다.

암호화된 값에는 ${해시함수정보}${반복횟수}${해시값}으로 구성된다.

public boolean matches(String password, String encodedValue) {
    String version = extractVersion(encodedValue);
    int cost = extractCost(encodedValue);
    String encodedPassword = extractEncodedPassword(encodedValue);

    String salt = generateSalt(version, cost);
    return encodedPassword.equals(toHash64(salt + rawPassword));
}

private String extractVersion(String encodedValue) {
    int firstIndex = encodedValue.indexOf(KEY_DELIMITER);
    int secondIndex = encodedValue.indexOf(KEY_DELIMITER, 1);

    return encodedValue.substring(firstIndex + 1, secondIndex);
}

private Integer extractCost(String encodedValue) {
    int secondIndex = encodedValue.indexOf(KEY_DELIMITER, 1);
    int lastIndex = encodedValue.lastIndexOf(KEY_DELIMITER);

    return Integer.parseInt(encodedValue.substring(secondIndex + 1, lastIndex));
}

private String extractEncodedPassword(String encodedValue) {
    int lastIndex = encodedValue.lastIndexOf(KEY_DELIMITER);
    return encodedValue.substring(lastIndex + 1);
}

이 방법은 서버의 코드 내부에서 해당 메소드로 인자를 넣어 실행시켜야 소금을 알 수 있기 때문에
서버의 코드를 해커가 알지 못하면 안전하다고 판단했다.


결론

암호화를 공부하면서 어려운점도 많았지만 서버 개발자 입장에서 보안도 중요하다는 걸 많이 생각하게 됐다.

댓글남기기