비밀번호 암호화

BG·2021년 6월 3일
0

목록 보기
1/1

웹사이트의 회원정보 중 암호화가 필수인 몇가지 컬럼들이 있습니다.
개인정보에 해당하는 것들이 암호화 대상이 되겠지만, 그중에서도 가장 중요한 것중에 하나인 비밀번호 암호화에 대해서 적어보고자 합니다.

Hash


[해쉬란?]
가변적인 길이의 데이터를 고정된 길이의 임의의 데이터로 변환하는 것이다.
해쉬함수를 통하여 가변적인 길이의 데이터를 고정된 길이의 데이터로 변환 시키는 것을 해싱(hashing)한다라고 한다.

그렇다면 해싱은 왜 하는것일까요?
해쉬 알고리즘의 원목적은 빠른 탐색을 하기 위함입니다.

예를 들어 보겠습니다.
사과, 자두, 바나나, 파인애플, 방울토마토 이렇게 길이가 5인 리스트가 있다고 하면 방울토마토를 찾기 위해 가장 간단하게는 for loop를 돌려서 방울토마토를 찾아낼 수 있습니다.
일반 loop문 이기에 최악의 경우 아래와 같이 다섯번의 탐색을 해야합니다.

# fruits 리스트에 담긴 방울토마토는 가장 마지막인 다섯번째에서 탐색이 완료된다.

fruits = ['사과', '자두', '바나나', '파인애플', '방울토마토']

for i in range(0, len(fruits)):
  if fruits[i] == '방울토마토':
    print(f'과일 : {fruits[i]}')
    print(f'{i + 1}번째에서 탐색 되었습니다.')
    break

[결과]
과일 : 방울토마토
5번째에서 탐색 되었습니다.

하지만 데이터 수백만개가 있는 리스트에서도 마찬가지로 for loop를 돌려야 할까요?

아래의 소스코드를 보면 hash_key_test함수 에서 나온 결과값을 dictionary 의 key값으로 하여 과일들을 hash_dict에다가 넣어주고 있습니다.

단순히 for loop를 돌려서 방울토마토를 찾을 때는 최악의 경우에 다섯번 탐색을 해야 하지만, 해싱을 하여 값을 담은 dictionary에서 찾을 때는 해싱하여 나온 키로 dictionary에서 값을 가져와 for loop를 돌려서 찾으면 최악의 경우라도 두번이면 값을 찾을 수 있습니다.
일반 for loop는 최악의 경우에 다섯번이지만 해쉬 알고리즘을 이용하였더니 두번으로 줄어든 것입니다.
이렇듯 해쉬 알고리즘은 빠른 탐색을 위한 알고리즘의 한 종류입니다.

아주 간단한 예시이지만 이러한 구조의 알고리즘을 해쉬 알고리즘이라고 하며 사과, 자두, 바나나, 파인애플, 방울토마토의 문자길이 %2 연산을 하는 과정을 해싱한다라고 합니다.
(%2로 연산을 하면 항상 고정적으로 0또는 1이라는 값을 얻을 수 있습니다.)

이렇듯 해싱은 가변적인 길이의 값을 고정적인 값으로 변화 시키는 과정이라고 생각하시면 됩니다.

아래의 코드는 해싱함수인 hash_key_test 함수를 얼마나 효율적으로 짜느냐에 따라서 해쉬 알고리즘의 효율성을 극대화 시킬 수 있습니다.

fruits = ['사과', '자두', '바나나', '파인애플', '방울토마토']
hash_dict = {}

def hash_key_test(str):
  # % 2를 하게 되면 0과 1값만을 가지는 key가 된다.
  hash_key = len(str) % 2
  return hash_key

def hash_test(str):
  hash_key = hash_key_test(str)
  if not hash_dict.get(hash_key): hash_dict[hash_key] = [str]
  else: 
    list = hash_dict[hash_key]
    list.append(str)
    hash_dict[hash_key] = list

for fruit in fruits:
  hash_test(fruit)

fruit_list = hash_dict[hash_key_test('방울토마토')]

for i in range(0, len(fruit_list)):
  if fruit_list[i] == '방울토마토':
    print(f'과일 : {fruit_list[i]}')
    print(f'{i + 1}번째에서 탐색 되었습니다.')
    break


[hash_dict 내용]
{0: ['사과', '자두', '파인애플'], 1: ['바나나', '방울토마토']}

[결과]
과일 : 방울토마토
2번째에서 탐색 되었습니다.

암호화


암호화란 의미없는 어떠한 문자&숫자의 조합으로 값을 바꾸는 것입니다.
여기서 해싱의 개념이 등장하게 됩니다.

앞서 과일을 해싱하여 0또는 1의 값으로 변환 시키는 작업의 예시를 보여드렸습니다.

암호화는 앞서 보여드린 단순한 %2 연산으로 해싱을 하닌 것이 아닌 아주 복잡하게 이루어진 어떠한 해쉬로직에 의해서 의미 없는 문자&숫자의 조합으로 변환을 시킵니다.

예를들어 '사과'를 암호화 알고리즘에 넣어 해싱하면 'a!sd@4dfjh4*&ff' 이라는 값을 얻게 되며, 이처럼 아무의미 없는 문자&숫자의 조합이기에 원래의 값이 무엇이었는지 도무지 알수 없게 되어 버립니다.

그렇다면 사과를 해싱하여 얻은 'a!sd@4dfjh4*&ff'의 값을 다시 '사과'로 만들수 있을까요?

정답은 할 수도 있고, 못 할 수도 있다입니다.
즉, 복호화가 가능한 암호화 방법이 있으며, 복호화가 불가능한 암호화도 있습니다.

복호화가 불가능한 것을 단방향 암호화, 복호화가 가능한 것을 양방향 암호화라고 합니다.

단방향 암호화


단방향 암호화는 한번 해싱을 하여 암호화를 진행 하면 복호화가 불가능 하기 때문에, 원래의 값을 잊어 버리면 찾을 방법이 없습니다.

단방향 암호화 알고리즘에는 여러가지 종류가 있겠지만, 대표적으로 MD5, SHA-1, SHA-2등이 있습니다.

그렇다면 복호화가 불가능 하니 가장 막강한 암호화 기법 아니냐? 라고 물어 보시는 분들이 계실 수도 있으나, 레인보우 테이블이라는 것이 있습니다.

이것이 무엇이냐 하면 MD5, SHA-1, SHA-2등의 단방향 암호화를 해싱하여 얻을수 있는 값들을 어마어마하게 저장해 놓은 테이블이라고 생각하시면 됩니다.

이 레인보우 테이블을 이용하여 무작위 대입법(Brute Force)으로 일치하는 암호문을 찾아 대입하게 된다면 로그인이 가능하게 되어버리는 것입니다.

웹사이트에서 사용하는 단방향 암호화


웹사이트를 구축할때 여러 암호화중에 단방향 암호화인 SHA-256 해쉬 알고리즘을 이용하는 bcrypt를 많이 사용합니다.

이 bcrypt는 단방향 암호화 방식이며 레인보우 테이블 공격의 약점을 보완하기 위해 salting(소금치고), key stretching(늘리는) 방법을 사용하고 있습니다.

salting하는 과정은 '사과'라는 문자가 있으면, '사과' + '임의의 랜덤한 값'을 더하는 과정입니다.

그리고 salting한 값에 key stretching이라는 과정을 거치게 되는데 key stretching은 해싱을 여러번 하는 것입니다.

해싱을 하여 나온 값을 또 해싱하여 값을 바꾸고 다시 또 해싱하는... 그런과정을 몇번을 반복하면 레인보우 테이블 공격을 받는 다고 하더라도 1초에 50억번 비교가능한 값에서 1초에 5번 정도 비교되는 값으로 변경되게 됩니다.

그러면 해킹에 소요되는 시간이 기하급수적으로 늘어나기에 완벽하게 안전하다고는 할수 없지만 상당히 안전한 암호화가 됩니다.

bcrypt


bcrypt는 여러 언어에서 지원을 하고 있습니다만, 아래의 소스코드는 파이썬 기반으로 작성 되었습니다.

그럼 bcrypt를 사용하여 salting하고 key streching을 하는 예제를 만들어 보겠습니다.

bcrypt.gensalt()를 이용하여 먼저 소금을 칩니다(난수화).

참고로 gensalt()를 찍어 보면 아래와 같이 의미를 알수 없는 랜덤함 값을 가집니다.

# gensalt()는 호출할 때마다 항상 랜덤한 값을 리턴합니다.
salt1 = bcrypt.gensalt()
salt2 = bcrypt.gensalt()

print(salt1)
print(type(salt1))

print(salt2)
print(type(salt2))

[결과]
b'$2b$12$mKOLHb/v4IkDEcA1BkxsPu'
bytes
b'$2b$12$pdVNVltVqqCcgUEUnWGnZe'
bytes

# 문자 앞에 b라고 붙어 있는 것은 파이썬에서 bytes type을 나타내는 키워드 입니다.

그리고 위의 결과를 통해 받아온 소금을 기존 패스워드에 양념을 칩니다.
(기존 패스워드는 '사과'라고 하겠습니다.)

'사과' + '$2b$12$mKOLHb/v4IkDEcA1BkxsPu'

이렇게 합쳐진 패스워드를 hashpw를 사용하여 key streching을 하게됩니다.

이렇게 소금을 치고 해싱 하는 과정을 한꺼번에 해주는게 bcrypt.hashpw()이며, hashpw는 bytes type으로 파라미터를 넘겨 주어야 하기 때문에 기존 패스워드에 encode() 처리를 합니다.

hashed_pw = bcrypt.hashpw('사과'.encode('utf-8'), bcrypt.gensalt())
print(hashed_pw)

[결과]
b'$2b$12$.2Eu/al1hmAlmIxpShp7F.oU.b5fX4n14aWRm9ozyVXVbhyyc3rde'

hashpw()로 만들어진 데이터의 값은 bytes type이므로 혹시 데이터베이스에 넣으실 계획이라면 다시 string type으로 decode()를 해주셔야 합니다.

그렇다면 완성된 암호문을 가지고 로그인 과정에서는 어떻게 정합한 패스워드인지 확인 할까요?

암호문을 다시 복호화 해서 입력되어진 패스워드와 같은지 비교해야 할까요?
아닙니다. bcrypt는 앞서 말씀드린 바와 같이 단방향 암호화이기 때문에 암호문을 다시 복호화 할 수는 없습니다.

회원가입을 할때 만들어진 패스워드의 암호문을 복호화 하는 것이 아니라 로그인 과정에서 입력되어진 패스워드를 회원가입때와 똑같은 과정으로 암호화 하여 그둘을 비교하게 되므로 결국 최초 본래의 패스워드는 무엇인지는 모르지만 로그인은 가능한 로직이 완성되는 것입니다.

이런 과정을 간편하게 해주는 것이 bcrypt의 checkpw()입니다.
회원가입시 암호화된 패스워드를 checkpw() 내부 로직에서 해독하여 어떤 소금을 사용하였는지 찾아내고 그 찾아낸 소금으로 로그인 과정에서 입력되어진 패스워드에 소금치고 늘려서 암호문으로 바꾼 다음 둘을 비교하게 됩니다.

일치 한다면 True를 반환하고, 불일치 한다면 False를 반환하게 되므로 패스워드의 정합성을 확인할 수 있습니다.

import bcrypt

sec_pw = bcrypt.hashpw('사과'.encode('utf-8'), bcrypt.gensalt())

bValid = bcrypt.checkpw('사과'.encode('utf-8'), sec_pw)

print(bValid)

[결과]
True
profile
글쎄...?

0개의 댓글