bcrypt
고친 사람 github-actions[bot]
bcrypt 는 비밀번호를 저장해도 되는 값으로 바꿉니다. 그 계산을 일부러 오래 걸리게 만듭니다. 얼마나 오래 걸리게 할지는 숫자 하나로 정해 두고, 장비가 좋아지면 그 숫자만 올립니다. 훔쳐 간 목록을 들고 비밀번호를 하나씩 맞춰 보려는 공격자의 비용을 그만큼 키우는 것이 목적입니다.
쉽고 빠른 이해
bcrypt 는 비밀번호를 받아 문자열 하나로 바꿔 줍니다. hunter2 를 넣으면 $2b$12$ 로 시작하는 예순 글자짜리 문자열이 나오고, 저장하는 것은 이 문자열입니다.
이게 없으면 목록을 훔쳐 간 공격자가 흔한 비밀번호 후보를 초당 수없이 대입해 볼 수 있습니다. 계산 한 번을 무겁게 만들면 같은 장비로 훨씬 적은 후보밖에 못 넣습니다.
어떻게 도나:
- 사용자마다 다른 무작위 값인 솔트를 하나 뽑습니다
- 솔트와 비밀번호로 암호 계산을 준비하는 작업을 정해진 횟수만큼 되풀이합니다
- 그 상태로 정해진 값 하나를 암호화합니다. 솔트와 되풀이 횟수와 그 결과를 한 문자열에 담습니다
대가는 서버도 같은 비용을 치른다는 것입니다. 로그인 한 번마다 그 계산을 서버가 하므로, 되풀이 횟수를 올리면 로그인 응답도 그만큼 늦어집니다.
상세
bcrypt 는 비밀번호를 저장용 값으로 바꾸는 함수입니다. 해시 함수와 하는 일은 비슷합니다. 다른 점은 계산을 몇 번 되풀이할지를 밖에서 정해 넣을 수 있다는 것입니다. 그 되풀이 횟수를 정하는 숫자를 비용 인자라고 부릅니다.
비밀번호에 왜 오래 걸리는 계산이 필요한지부터 봅니다.
오래 걸리는 계산이 필요한 까닭
비밀번호를 적힌 그대로 저장하면 목록이 새는 순간 전부 털립니다. 그래서 되돌릴 수 없는 값으로 바꿔 저장합니다. 로그인할 때마다 같은 계산을 다시 해서 견줍니다.
문제는 해시 함수가 계산이 빨리 끝나도록 만든 물건이라는 점입니다. 공격자도 그 속도를 그대로 누립니다. 흔한 비밀번호 목록을 놓고 후보를 하나씩 계산해 견주면 됩니다. 한 번이 싸면 백만 번도 쌉니다.
솔트는 이 문제를 풀지 못합니다. 솔트는 사용자마다 다른 무작위 값을 함께 넣어 미리 계산해 둔 표를 못 쓰게 만드는 장치입니다. 표를 못 쓰게 할 뿐, 그 사람의 솔트를 넣어 후보를 하나씩 대입하는 계산은 여전히 금방 끝납니다.
남은 길은 계산 한 번에 드는 시간을 키우는 것입니다. 한 번 맞춰 보는 데 오래 걸리게 하면 같은 시간에 넣어 볼 수 있는 후보 수가 그만큼 줄어듭니다. 이렇게 일부러 계산을 무겁게 만드는 일을 키 스트레칭이라고 부르고, bcrypt 는 그 장치를 함수 안에 넣어 둔 것입니다.
입력과 출력
bcrypt 가 받는 것은 셋입니다. 비밀번호, 사용자마다 다른 솔트, 그리고 계산을 얼마나 되풀이할지 정하는 비용 인자입니다.
내놓는 것은 문자열 하나입니다. 솔트와 비용 인자와 계산 결과를 열 세 개에 나눠 담지 않아도 되도록, 셋을 한 문자열에 같이 적어 둡니다.
아래는 파이썬 bcrypt 라이브러리로 비밀번호 하나를 값으로 만들고, 그 값으로 다시 확인해 본 것입니다.
h = hashpw("hunter2", gensalt(cost=12))
h # "$2b$12$..." · 60자
checkpw("hunter2", h) # 맞다
checkpw("hunter3", h) # 아니다
만드는 함수에는 솔트와 비용 인자를 넘겼지만, 확인하는 함수에는 비밀번호와 저장된 문자열만 넘겼습니다. 확인에 필요한 조건이 그 문자열 안에 이미 들어 있기 때문입니다.
그 문자열은 네 조각으로 나뉩니다.
| 조각 | 담는 것 |
|---|---|
맨 앞 $2b$ |
이 값을 만든 bcrypt 의 갈래 |
이어지는 12$ |
비용 인자 |
| 다음 22자 | 솔트 |
| 나머지 31자 | 계산 결과인 해시값 |
열 하나에 이 문자열만 넣어 두면 검증에 필요한 것이 다 있습니다.
비용 인자
비용 인자는 되풀이 횟수를 그대로 적는 숫자가 아닙니다. 2를 그 숫자만큼 곱한 값이 되풀이 횟수가 됩니다. 비용 인자가 12 면 2를 열두 번 곱한 4,096 번입니다.
그래서 숫자를 하나 올리면 계산 시간이 두 배가 됩니다. 둘 올리면 네 배입니다. 장비가 좋아져 공격자의 시도 횟수가 늘면 이 숫자 하나를 올려 따라갑니다.
| 비용 인자 | 되풀이 횟수 |
|---|---|
| 10 | 1,024 |
| 12 | 4,096 |
| 14 | 16,384 |
bcrypt 의 계산량을 정하는 것은 이 숫자 하나뿐입니다. 입력이 길든 짧든 드는 시간은 거의 같습니다.
고르는 기준은 서버가 감당할 로그인 응답 시간입니다. 숫자를 올릴수록 공격자의 비용이 커지지만, 로그인마다 같은 계산을 하는 서버의 비용도 같이 커집니다. 로그인이 몰리는 서비스에서는 이 숫자가 곧 서버 용량 문제가 됩니다.
안에서 도는 계산
bcrypt 의 안쪽은 블록 암호에서 왔습니다. 블록 암호는 정해진 길이의 데이터 덩이를 키로 뒤섞는 함수입니다. bcrypt 가 가져다 쓴 블록 암호는 Blowfish 입니다.
블록 암호는 뒤섞기 전에 키를 풀어 내부 표를 채우는 준비 작업을 합니다. 이 준비 작업을 키 스케줄이라고 부릅니다. 앞에서 「암호 계산을 준비하는 작업」이라고 한 것이 이것입니다.
bcrypt 가 손댄 곳이 그 준비 작업입니다. 키 스케줄을 한 번만 하지 않습니다. 솔트와 비밀번호를 번갈아 먹이며 비용 인자가 정한 횟수만큼 되풀이합니다.
되풀이가 끝나면 그 상태로 정해진 값 하나를 여러 번 암호화합니다. 그 값은 bcrypt 가 붙박이로 들고 있는 짧은 문자열입니다. 언제나 같은 값이라, 결과를 가르는 것은 앞의 되풀이가 만든 상태뿐입니다. 마지막에 나온 암호문이 해시값입니다.
flowchart TD
S["솔트 · 비밀번호"] --> K["키 스케줄"]
K --> R["비용 인자가 정한 횟수만큼 되풀이"]
R --> K
R --> E["정해진 값을 암호화"]
E --> H["해시값"]
그림에서 볼 것은 되풀이가 준비 작업에 걸려 있다는 점입니다. 암호화는 맨 끝에 한 번뿐입니다. 비용은 거의 전부 키 스케줄을 되풀이하는 데서 나옵니다.
이 준비 작업은 내부 표를 채우느라 메모리를 몇 킬로바이트 씁니다. 그래서 같은 계산을 수천 개씩 동시에 돌리는 그래픽 카드에서는 이득이 덜 납니다. 표를 계산 단위마다 따로 둬야 해서 한 장비에 밀어 넣을 수 있는 계산 수가 줄기 때문입니다.
다만 몇 킬로바이트는 큰 값이 아닙니다. 메모리를 훨씬 많이 쓰도록 설계한 scrypt나 Argon2에 비해 bcrypt 는 전용 장비를 짜서 공격하기가 덜 까다롭습니다. 새로 고를 때 그 둘이 먼저 거론되는 까닭이 여기 있습니다.
입력 길이 한계
bcrypt 는 비밀번호를 72바이트까지만 씁니다. 그보다 긴 입력은 뒤가 잘려 나가 결과에 영향을 주지 않습니다.
한글 한 글자를 몇 바이트로 적느냐는 문자 인코딩이 정합니다. 널리 쓰는 UTF-8(Unicode Transformation Format 8-bit, 유니코드 변환 형식 8비트)은 한글 한 글자를 3바이트로 적습니다. 그러니 한글로만 지은 비밀번호는 스물네 글자쯤에서 이 한계에 닿습니다.
이 한계는 조용합니다. 오류를 내지 않고 그냥 잘립니다. 긴 문구를 비밀번호로 받는 서비스라면 앞부분이 같은 두 문구가 같은 값으로 저장될 수 있습니다.
검증하는 순서
로그인할 때 서버가 하는 일은 저장된 문자열에서 조건을 꺼내는 것으로 시작합니다. 앞에서 본 네 조각이 각각 어디로 흘러드는지를 그리면 이렇습니다.
flowchart TD
S["저장된 문자열"] --> V["갈래 · 비용 인자"]
S --> T["솔트"]
S --> H["저장된 해시값"]
V --> C["받은 비밀번호로 같은 계산을 다시 한다"]
T --> C
C --> R["새로 나온 값"]
R --> X["견준다"]
H --> X
갈래와 비용 인자와 솔트는 계산을 다시 하는 데 쓰이고, 해시값만 끝에서 견주는 데 쓰입니다.
되돌리는 계산은 없습니다. 저장된 값에서 비밀번호로 돌아가는 길이 마련되어 있지 않으니, 같은 조건으로 다시 계산해 견주는 것이 유일한 확인 방법입니다.
견줄 때는 두 값이 어긋나는 곳에서 바로 멈추지 않고 끝까지 훑는 비교 함수를 씁니다. 언제 멈췄는지를 재서 앞부분이 맞았는지 알아내는 공격이 있기 때문입니다. 이런 공격을 타이밍 공격이라고 부릅니다.
쓰는 곳과 안 쓰는 곳
비밀번호를 다루는 곳마다 bcrypt 를 써도 되는지 따져 봅니다. 가르는 기준은 하나입니다. 사람이 고른 비밀번호를 놓고 누가 후보를 대입해 볼 수 있는 상황인가입니다.
| 쓰임 | bcrypt 로 괜찮나 |
|---|---|
| 사용자 비밀번호 저장 | 됩니다 |
| 이미 bcrypt 로 저장해 온 서비스 | 됩니다. 비용 인자만 올려 갑니다 |
| 새로 고르는 경우 | Argon2·scrypt 처럼 메모리를 많이 쓰는 함수가 먼저 거론됩니다 |
| 긴 문구를 비밀번호로 받는 곳 | 72바이트 한계를 먼저 따집니다 |
| 파일이 깨졌는지 확인 | 아닙니다. 그 일은 해시 함수가 합니다 |
| 암호화 키 만들기 | 아닙니다. 원하는 길이의 키를 뽑는 함수를 씁니다 |
후보를 대입해 볼 수 있는 상황이 아니면 계산을 무겁게 할 이유가 없습니다.
관련 항목
bcrypt 가 속하는 상위 분류
비밀번호 해싱 · 키 유도 함수 · 해시 함수 · 암호학적 해시 함수 · 해시
bcrypt 를 대신할 수 있는 다른 함수
Argon2 · Argon2id · scrypt · PBKDF2 · yescrypt
bcrypt 가 입력으로 함께 받는 값
솔트 · 페퍼 · 비용 인자 · 키 스트레칭
비밀번호를 알아내려고 쓰는 공격
레인보우 테이블 · 무차별 대입 공격 · 사전 공격 · 크리덴셜 스터핑 · 타이밍 공격
bcrypt 의 계산 비용을 좌우하는 장치와 장비
Blowfish · 블록 암호 · 키 스케줄 · GPU · ASIC
bcrypt 값으로 사람을 들여보내는 절차
인증과 인가 · 세션 · 액세스 토큰 · 비밀번호 재설정 · 다중 인증
비밀번호 저장에 쓰면 안 되는 범용 해시 함수
다른 이름: 비크립트