Skip to content

Latest commit

 

History

History
500 lines (404 loc) · 20.7 KB

비밀번호_암호화.md

File metadata and controls

500 lines (404 loc) · 20.7 KB

비밀번호 암호화

이 문서에서는 비밀번호를 암호화해야 하는 이유와 방법을 다룹니다.

먼저 '암호화'란, '통신의 원문을 일정한 암호 시스템에 따라 암호문으로 바꾸는 일'로 정의됩니다. 쉽게 말해, 중요한 정보를 감추기 위해 설계된 알고리즘에 의해 정보를 알아보기 힘들게 가공하는 일을 뜻합니다. 암호화된 정보를 암호문이라고 하며, 암호화 전의 원래 형태의 정보를 평문이라고 합니다. 평문을 암호문으로 바꾸는 작업은 암호화라고 하며, 반대로 암호문을 평문으로 바꾸는 작업은 복호화(또는 해독, decryption)라고 합니다.

2019년 3월, 페이스북은 약 6억 개의 암호화되지 않은 비밀번호를 노출한 대형 보안 사고를 일으켰습니다. 이 사고에는 크게 두 가지 문제가 존재했습니다. 첫 번째는 비밀번호가 유출되었다는 것이며 두 번째는 그 비밀번호가 암호화되지 않은 채로 페이스북 서버에 저장되어 있었다는 사실입니다. 페이스북과 같은 멀티 유저 서비스는 주로 아이디와 비밀번호를 통해 개인을 식별합니다. 이러한 비밀번호는 사용자가 여러 다른 서비스에서도 같은 비밀번호를 사용하고 있을 확률이 높습니다. 따라서 그중 하나의 서버에서 비밀번호가 유출된다면 해당 비밀번호를 사용하던 모든 서비스의 계정의 보안 위협을 받습니다. 때문에 개발자는 비밀번호를 암호화하여 저장하도록 하고 있습니다. 하지만 서버는 사용자가 로그인하기 위해 입력한 비밀번호와 이미 서버에 저장되어 있는 비밀번호를 대조하여 비밀번호의 일치 여부를 확인해야 합니다. 이런 상황에 좋은 암호화 알고리즘은 무엇일까요?

대한민국의 개인정보 보호법에 의한 개인정보의 안전성 확보조치 기준 제 7조의 2에 의하면, 비밀번호의 경우는 단방향 암호화하여 저장 해야 합니다. 단방향 암호화(또는 일방향 암호화)란, 암호화는 가능하지만 복호화는 불가능한(또는 불가능에 가까운) 암호화 방식입니다. 단방향 암호화의 대표적인 방식으로는 '해시 함수'가 있습니다. 그리고 해시 함수에 의해 암호화된 암호문을 다이제스트라고 합니다.

해시 함수는 여러 알고리즘을 가지고 있습니다. SHA-1 알고리즘의 해시 함수를 통해 12345라는 비밀번호를 암호화하면 다음과 같이 원문과 아무런 관련없는 암호문을 만들어냅니다.

8CB2237D0679CA88DB6464EAC60DA96345513964

우리는 이렇게 암호화된 암호문을 원문 대신 저장해야 합니다. 이를 통해 개발자나 해커가 위와 같은 암호문을 통해 원문, 즉 비밀번호가 무엇인지 예측할 수 없게 되기 때문입니다. 동일한 사용자가 로그인을 위해 12345를 입력한다면 해시 함수는 다시 위와 같은 값을 결과로 낼 것이며, 이 값과 미리 저장된 값을 대조하였을 때 두 값은 일치하므로 원문인 12345도 동일하다는 것을 유추할 수 있습니다.

이러한 해시 함수에는 다음과 같은 특성이 있습니다. (다음과 같은 특성으로 보안 효과를 누릴 수 있다는 이론적 설명이며, 필요하지 않다면 넘어가도 좋습니다.)

해시 함수의 특성

입력값 x와 해시함수값 y에 대하여, 다음과 같은 특성을 갖습니다.

결정론적

결정론적 알고리즘(deterministic algorithm)은 예측한 그대로 동작하는 알고리즘이다.

- Wikipedia

입력 x에 대하며 출력 y가 항상 동일하다는 뜻입니다.

단방향성 (또는 역상 저항성, 역상에 대한 안정성)

y만 확인할 수 있는 상황에서 입력값 x를 찾는 것이 계산적으로 불가능합니다. 쉽게 말해, 해시함수를 통해 암호화된 암호문만으로는 원문을 찾는 것이 계산적으로 불가능하다는 것을 뜻합니다. 만약 저장된 값인 y가 유출되거나 개발자가 나쁜 의도를 갖고 접근해도 사용자의 실제 비밀번호를 알 수 없도록 합니다.

제 2 역상 저항성(또는 제2 역상에 대한 안정성)

동일한 해시함수값 y를 내는 다른 입력값 x'을 찾는 것이 계산적으로 불가능합니다. 위에서 언급한 입력값 12345에 대한 해쉬값을 12345가 아닌 입력값에서 출력값으로 나올 것을 찾을 수 없다는 것 입니다. 이를 통해 사용자가 틀린 비밀번호를 입력하면 다른 해시 값이 나올 것이며 이 값이 저장된 값이 다르므로 비밀번호가 틀렸음을 유추해낼 수 있습니다.

충돌 저항성(또는 충돌에 대한 안정성)

동일한 해시함수값 y를 갖는 서로 다른 x와 x'를 찾는 것이 계산적으로 불가능합니다. 이는 위의 제 2 역상 저항성의 부수효과이며 부분집합입니다.

눈사태 효과(또는 산사태효과, 쇄도 효과)

쇄도 효과(avalanche effect), 산사태 효과는 어떤 암호 알고리즘이 입력값에 미세한 변화를 줄 경우 출력값에 상당한 변화가 일어나는 성질을 의미한다.

- Wikipedia

예를 들어 입력값 x에 문자 'A'를 추가하는 것과 같은 작은 변화로도 결과 y는 이전과 전혀 다르게 나오는 것을 의미합니다. 실제로 1234512345ASHA-1 해시 함수값은 아래와 같이 크게 다르며 어떠한 규칙성도 찾을 수 없습니다.

x y
12345 8CB2237D0679CA88DB6464EAC60DA96345513964
12345A 343962C7BA7E2CB2781441D7CA266608B378C59C

하지만 이렇게 완벽해보이는 해시 함수도 몇 가지 보안 취약점을 지니고 있습니다.

레인보우 테이블

레인보우 테이블이란, 거의 모든 비밀번호에 대한 해시 값을 저장해놓은 표입니다.

hash password
8CB2237D0679CA88DB6464EAC60DA96345513964 12345
343962C7BA7E2CB2781441D7CA266608B378C59C 12345A
... ...

위와 같이 해쉬에 대응하는 비밀번호를 미리 저장해두고 찾아내어 원문을 알아내는 방법입니다. 이를 통해 원문 비밀번호를 탈취하는 공격 기법을 레인보우 공격(Rainbow attack)이라고 합니다. 하지만 비밀번호가 복잡해질수록 해시 값이 레인보우 테이블에 저장되어 있을 확률이 적어집니다. 때문에 대부분의 서비스에서 회원가입을 할 때는 비밀번호를 길게 작성하도록 합니다.

무차별 대입 공격과 사전 공격

사전공격

무차별 대입 공격(또는 억지 기법 공격, Brute-force attack)이란, 암호로 사용할 수 있는 모든 값을 대입해보는 것을 뜻합니다. 해시 함수 값 y를 만족하는 x를 찾기 위해 비밀번호가 될 수 있는 모든 문자열에 대한 해시 함수 값을 찾아보는 것입니다. 비슷한 방식인 사전 공격(또는 단어사전 공격, Dictionary attack)이란, 미리 정의한 문자열 목록인 사전에 있는 모든 문자열을 대입해보는 방식으로 12345와 같은 단순한 비밀번호를 사용할 경우, 사전에 정의되어 있을 가능성이 크므로 상황에 따라 사전 공격이 무차별 대입 공격보다 훨씬 빠를 수 있습니다. 이 공격 기법은 거의 대부분의 보안 체계의 취약점으로 존재합니다. 하지만 이 공격을 위해서는 굉장히 많은 시간과 비용이 소모됩니다. 때문에 어떠한 보안 체계의 취약점이 발견되었다는 것은 무차별 대입 공격보다 더 빠른 공격 기법이 존재하는 것을 뜻하게 됩니다. 하지만 나날이 발전해가는 컴퓨팅 파워로 이 공격 기법은 실제로 공격가능한 수준까지 우리에게 다가왔습니다.

인식 가능성

만약 유저 A의 비밀번호가 1234인 것을 알고 있으며 유저 B의 비밀번호는 모르는 상황에서 데이터베이스가 아래와 같이 유출되었다고 가정해봅니다.

name password
A 7110EDA4D09E062AA5E4A390B0A572AC0D2C0220
B 7110EDA4D09E062AA5E4A390B0A572AC0D2C0220
... ...

A와 B의 해시 값이 같으므로 원문인 비밀번호도 1234로 같다는 것을 유추해낼 수 있습니다. 이러한 특성을 인식 가능성(recognizability)이라고 합니다.

이러한 문제를 보완하기 위해 몇 가지 기술이 사용됩니다.

솔팅

솔팅(salting)이란, 원문에 솔트(salt)라는 작은 문자열을 추가해 주는 것을 의미합니다. 예를 들어 솔트 rGmBcN7UOxHxDdMRPV7SpO와 같은 짧지만 복잡한 문자열과 사용자가 입력한 비밀번호 12345를 합친 rGmBcN7UOxHxDdMRPV7SpO12345의 해시 값을 얻어 완전히 다른 해시 값을 저장할 수 있게 됩니다. 복잡한 문자열이 추가되었으므로 rGmBcN7UOxHxDdMRPV7SpO12345 중에서 어디서부터 어디까지가 실제 비밀번호인지 알 수 없고, 사전 공격에 사용되는 사전에 저장되어있을 확률도 크게 떨어트립니다. 동시에, 비밀번호가 더 길고 복잡해졌으므로 무차별 대입 공격에 소모되는 비용과 시간은 기하급수적으로 늘어납니다. 하지만 이는 인식 가능성 문제를 해결하지 못합니다. 때문에 해시 값을 생성할 때 마다 솔트 값도 다르게 생성하여 아래와 같이 해시 값과 솔트 값의 쌍으로 함께 저장해야합니다. A와 B는 서로 같은 비밀번호인 1234를 사용하는 데, 솔트 값이 달라서 해시 값도 크게 달리지므로 둘의 비밀번호의 연관성을 찾을 수 없게 됩니다.

name salt password
A abcd E7D537E128158790157EA057BB883E0292A84930
B efgh FD40E5E5090175F9945B6C56365D2252C2A5AF04
... ... ...

키 스트레칭

키 스트레칭(Key stretching)이란, 원문의 해시 값을 입력값으로 다시 해시 값을, 그 해시 값을 다시 입력값으로 갖는 해시 값을 얻는 것을 n번 반복하는 것입니다. 예를 들어 12345라는 비밀번호를 n=3SHA-1 알고리즘으로 키 스트레칭한다면 다음과 같은 결과를 얻을 수 있습니다.

횟수 입력 출력
1 12345 8CB2237D0679CA88DB6464EAC60DA96345513964
2 8CB2237D0679CA88DB6464EAC60DA96345513964 07116A2FE851F6BB6AFD12251E38EAB30480CD05
3 07116A2FE851F6BB6AFD12251E38EAB30480CD05 128EE234B0BC7DB8F27A64865E246BC663136637

즉, 해시함수를 SHA1라고 할 때, 128EE234B0BC7DB8F27A64865E246BC663136637 = SHA1(SHA1(SHA1(12345)))와 같습니다. 만약 우리의 데이터베이스가 노출되어 128EE234B0BC7DB8F27A64865E246BC663136637이라는 해시 값이 발견되었더라도 이의 원문인 07116A2FE851F6BB6AFD12251E38EAB30480CD05는 실제 비밀번호인 12345와 다르므로 실제 비밀번호는 안전합니다. 만약 키 스트레칭 횟수도 공격자도 알게 된다면, 동일한 횟수만큼 복호화를 할 것이지만 이에 따라 증가하는 시간과 비용은 기하급수적으로 늘어나게 됩니다.

비밀번호 저장을 위한 암호화 알고리즘 사용

비밀번호를 암호화하기 위한 해시 함수의 종류로 대표적인 bcrypt는 다음과 같은 형식을 가지고 있습니다.

// cost : (4~32 사이의)정수
// salt : 길이가 22인 문자열
// password : 문자열
// 반환값 : 길이가 60인 문자열(암호문)
bcrypt(cost: int, salt: string, password: string): string

bcrypt의 특징은 결과값에 솔트와 cost라는 이름의 키 스트레칭 반복 횟수를 결정하는 인자를 포함하는 것입니다. bcrypt는 2의 cost 제곱만큼 키 스트레칭합니다. 이를 표현하기 위해 결과값은 아래와 같은 모습을 하고 있습니다. 문자 $로 각 값을 구별합니다.

$[bcrypt 버전]$[cost]$[솔트][해시 값]

다음은 버전은 2a 버전을 사용하며 N9qo8uLOickgx2ZMRZoMye가 솔트이고 cost는 10인 bcrypt 암호문의 예시입니다. cost가 10이므로 (2의 10제곱)=1024번 키 스트레칭된 해시 값을 포함하고 있는 암호문입니다.

$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy

bcrypt의 버전은 여기서 확인하실 수 있습니다. 보안이 검증된 최신 버전을 사용할 것을 권장하며 작성일(2020년 4월) 기준 최신버전은 2b입니다. cost의 값은 10 내외로 설정하는 것을 권장합니다. 2의 cost 제곱만큼 연산을 하므로 cost의 값이 1만큼 커지면 실행시간은 2배만큼 늘어납니다. cost가 너무 작을 경우 안전하지 못하고, 너무 크면 연산을 위해 많은 시간을 써서 시스템이 느려지게 됩니다. 다음은 작성자의 컴퓨터에서 각각의 cost 값에 따른 실행 시간을 측정한 표입니다. cost 값을 설정할 때 참고해주세요. (이론과 같이 실행시간이 2배씩 늘어나는 것도 확인할 수 있습니다.)

결과 보기 작동 환경:
구분 이름
CPU Intel(R) Core(TM) i5-7600K CPU @ 3.80GHz
런타임 NodeJS v12.16.1
라이브러리 node.bcrypt.js

작동 시간:

cost 생성 시간(초) 대조 시간(초)
4 0.001 0.001
5 0.002 0.002
6 0.004 0.005
7 0.008 0.008
8 0.016 0.016
9 0.032 0.03
10 0.062 0.061
11 0.126 0.144
12 0.253 0.244
13 0.48 0.475
14 0.957 0.957
15 2.042 1.926
16 4.431 4.102
17 7.78 7.779
18 15.694 15.472
19 31.091 33.003

길고 복잡한 비밀번호의 사용 권장

이 부분은 우리가 사용자의 권해야 할 선택 사항입니다. 만약 사용자가 비밀번호를 복잡하고 길게 만들었다면 위와 같은 조치들과 함께 시너지를 일으켜 해커가 우리의 비밀번호를 알아내는 데 비현실적으로 큰 시간과 비용을 소모하도록 만듭니다. 너무 큰 시간을 소모하는 공격 기법은 대부분 몇 백년 이상 동안 연산을 해야 결과를 얻을 수 있으며 이는 공격 실패나 다름없습니다.


그럼 이제 실무에서 어떻게 코드를 작성해야하는지 배워봅니다.

NodsJS 예제

!주의! 이 예제는 비밀번호의 저장을 파일 시스템(fs)를 통해 진행합니다. 실무에서는 여러분의 환경에 맞게 사용해주세요.

우선 bcrypt 알고리즘을 사용하기 위해 bcrypt 모듈을 설치합니다.

npm i bcrypt

예제에서는 콘솔 입력을 통해 비밀번호를 받아옵니다.

// 비밀번호_저장.js
const fs = require('fs');
const bcrypt = require('bcrypt');

// 이 예제에서는 콘솔에서 입력을 받아오기
// 위해 아래와 같은 구문을 사용합니다.
// 지금 개발하고 있는 환경에 맞게
// 입력을 스스로 조정해주세요.
const [password] = process.argv.slice(2);

// cost와 saltRounds는 동치입니다.
const saltRounds = 10;

if (!password) {
  console.log('비밀번호를 입력하지 않았습니다!!!');
  process.exit(0);
}

if (password.length < 8) {
  console.log('비밀번호가 너무 짧습니다. 8글자 이상 입력해주세요!');
  process.exit(0);
}

bcrypt.hash(password, saltRounds).then((hash) => {
  // 이 예제에서는 비밀번호의 결과를 파일로 저장합니다.
  // 지금 개발하고 있는 환경에 맞게
  // 출력을 스스로 조정해주세요. [예시) MySQL]
  fs.writeFileSync(__dirname + '/비밀번호', hash);
  console.log('비밀번호가 저장되었습니다.');
});
// 로그인.js
const fs = require('fs');
const bcrypt = require('bcrypt');

// 이 예제에서는 콘솔과 파일에서 입력을 받아오기
// 위해 아래와 같은 구문을 사용합니다.
// 지금 개발하고 있는 환경에 맞게
// 입력을 스스로 조정해주세요.
const [password = ''] = process.argv.slice(2);
const hash = fs.readFileSync(__dirname + '/비밀번호', 'utf8');

bcrypt.compare(password, hash).then((result) => {
  if (result) {
    console.log('로그인하셨습니다.');
  } else {
    console.log('비밀번호가 틀렸네요.');
  }
});
node 비밀번호_저장.js 123
>>> 비밀번호가 너무 짧습니다. 8글자 이상 입력해주세요!
node 비밀번호_저장.js asdf1234
>>> 비밀번호가 저장되었습니다.
node 로그인.js as
>>> 비밀번호가 틀렸네요.
node 로그인.js asdf1234
>>> 로그인하셨습니다.

PHP 예제

!주의! 이 예제는 비밀번호의 저장을 파일 시스템를 통해 진행합니다. 실무에서는 여러분의 환경에 맞게 사용해주세요.

<!-- signup.html -->
<form action="signup.php" method="POST">
  <!--
  비밀번호를 포함하는 내용의 요청은 
  POST method로 진행해야합니다!!!
  -->
  <input type="password" name="password" />
  <input type="submit" value="비밀번호 등록" />
</form>
// signup.php
$password = $_POST['password'];

if (!$password) {
    die('비밀번호를 입력하지 않았습니다!!!');
}

if (strlen($password) < 8) {
    die('비밀번호가 너무 짧습니다. 8글자 이상 입력해주세요!');
}

$hash = password_hash($password, PASSWORD_BCRYPT, [
    // 세번째 인자에서 cost 값을 지정해줄 수 있다.
    // 기본값은 12이다.
    'cost' => 10
]);

// 이 예제에서는 비밀번호의 결과를 파일로 저장합니다.
// 지금 개발하고 있는 환경에 맞게
// 출력을 스스로 조정해주세요. [예시) MySQL]
file_put_contents('password', $hash);
echo "등록되었습니다.";
<!-- login.html -->
<form action="login.php" method="POST">
  <!--
  비밀번호를 포함하는 내용의 요청은 
  POST method로 진행해야합니다!!!
  -->
  <input type="password" name="password" />
  <input type="submit" value="로그인" />
</form>
// login.php
$password = $_POST['password'];
$hash = file_get_contents('password');

if (password_verify($password, $hash)) {
    echo "로그인 성공!";
} else {
    echo "비밀번호가 틀렸습니다.";
}

참고 자료