들어가며
사실 지금까지 프론트엔드를 작업하면서 Https에 의존하며, 암호화에 대해 깊이 고민해본 적이 없었습니다. 그러나 이번에 정글에서 백엔드 부분도 개발을 맡게 되고, 처음으로 암호화에 대해 진지하게 고민을 하게 되었습니다. 이 글에서는 비밀번호 암호화에 적합한 방식, 사용가능한 라이브러리, 그리고 암호화가 이루어지는 원리에 대해 정리해보았습니다.
단방향 암호화
비밀번호를 암호화 할 때는 단방향 암호화를 진행해야 합니다. 그런데 단방향 암호화는 어떤 뜻일까요?
단방향 암호화는 데이터를 암호화할 때, 해당 데이터를 다시 복호화할 수 없는 방식의 암호화 기법입니다. 이는 일반적으로 해시 함수를 사용하여 이루어지며, 해시 함수는 입력 데이터를 고정된 크기의 해시 값으로 변환합니다. 이 변환 과정은 비가역적이며, 원래의 데이터를 해시 값으로부터 복원할 수 없습니다.
즉, 단방향 암호화는 다시 되돌릴 수 없는 암호화 방식입니다. DB가 통째로 노출된다고 해도, 원래의 데이터 값으로 복원할 수 없기에 안전합니다. 그렇다면 단방향 암호화는 어떻게 구현할 수 있을까요? 단방향 암호화를 지원하는 대표적인 알고리즘 중 하나가 해쉬 함수 입니다.
해쉬 함수(Hash Function)
해쉬 함수는 입력 데이터의 크기에 상관없이, 항상 고정된 크기의 해시 값으로 변환합니다. 이 과정은 다음과 같이 이루어집니다.
입력 데이터: "Hello, World!"
0. 입력 데이터를 바이트로 변환: 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21
1. 데이터 패딩: 메시지 길이에 따라 패딩이 추가됨.
2. 데이터 분할: 패딩된 데이터를 512비트 블록으로 분할.
3. 해시 함수 처리: 각 블록을 순차적으로 처리하여 최종 해시 값을 생성.
4. 출력: 7f83b1657ff1fc53b92dc18148a1d065f7b9209fb8d6e97a18c2ab8d66f9d843
1. 데이터 패딩(padding):
입력 데이터의 크기가 해시 함수에 필요한 블록 크기로 맞춰지도록 패딩이 추가됩니다. 이 해시 함수에 필요한 블록 크기는 함수마다 다릅니다만, 보통 블록 크기의 배수가 되도록 0이나 특정 패턴이 추가됩니다.
2. 데이터 분할(chunking)
패딩된 데이터는 해시 함수의 내부 구조에서 처리될 수 있도록 고정 크기의 블록으로 나뉘어집니다.
3. 해시 함수 적용:
해시 함수는 이 데이터 블록들을 순차적으로 처리해가며, 블록마다 연산을 통해 중간 해시 값을 생성해 나갑니다. 이 과정에서 처음 입력 데이터는 복잡한 수학적 연산을 통해 특정 패턴을 발견하기 어렵게 만들어집니다.
4. 출력(digest):
그렇게 모든 블록이 처리되면, 최종적으로 고정된 길이의 해시 값(해시 다이제스트)이 생성됩니다.
대표적인 해시 함수로는 MD5, SHA-1, 그리고 SHA-256이 있습니다. 하나씩 짧게 알아보겠습니다. 먼저, MD5는 128비트의 해시 값을 생성합니다. 그러나 충돌이 쉽게 발생할 수 있어서 현재는 안전하지 않은 것으로 간주되는데요. 여기서 충돌이 뭘까요?
충돌은 서로 다른 입력 데이터가 동일한 해시 값을 가지는 경우를 충돌이라고 합니다.
이렇게 되면 무작위로 데이터를 넣어 돌렸을 때, 비밀번호를 맞출 확률이 높아지겠죠. 그래서 좋은 해시 함수는 이러한 충돌이 발생할 가능성을 최소화하도록 설계되어 있습니다.
SHA-1은 MD5보다 높은 160 비트의 해시 값을 생성하지만, 현재는 충돌 가능성이 발견되어 사용이 줄어들고 있습니다. 마지막으로 가장 많이 알려진 SHA-256은 256비트의 해시 값을 생성하는 해시 함수이며, 현재 가장 널리 사용되는 해싱 알고리즘으로 알려져 있습니다.
키 파생 함수(Key Derivation Function)
키 파생함수(KDF)는 암호화에 적합한 고강도 키를 생성하는데 사용됩니다. 해시 알고리즘을 사용하는 것은 같지만, 해시 함수에 비해 계산이 느리고, 보안 강화 요소가 더 많이 포함되어 있습니다. 계산이 느린 이유는 아까 충돌에 대해 배웠었죠? 무작위의 데이터를 넣어 해커가 공격하는, 무차별 대입 공격에 필요한 시간과 자원을 증가시키기 위함입니다.
그리고 KDF(키 파생 함수)는 Salt라는 특별한 키를 사용하는 것이 특징입니다. 임의의 고유 데이터를 사용해 동일한 비밀번호라도 다른 salt 값을 사용해서 서로 다른 해시 값을 만들어 내도록 합니다. 이렇게 하면 동일한 비밀번호에 대한 사전 공격을 방지하는데 도움이 됩니다.
Node.js를 사용하신다면 많이 이미 한 번쯤 사용해보셨을 bcrypt도 바로 이 키파생함수의 주요 알고리즘 중 하나입니다. bcrypt를 사용한 암호화 과정을 살펴보겠습니다.
const bcrypt = require('bcrypt');
// 비밀번호를 해싱하는 함수
async function hashPassword(password) {
const saltRounds = 10; // 해싱 반복 횟수를 정할 수 있음
const hashedPassword = await bcrypt.hash(password, saltRounds);
return hashedPassword;// 암호화된 비밀번호 해쉬 값을 리턴
}
// 비밀번호를 검증하는 함수
async function checkPassword(password, hashedPassword) {
const match = await bcrypt.compare(password, hashedPassword);
return match; // 비밀번호 일치 여부 리턴
}\
1. 비밀번호 해싱
bcrypt.hash를 사용하여 비밀번호를 해싱합니다. saltRounds는 해싱의 반복 횟수를 의미하며, 값이 높을수록 더 안전하지만 계산이 더 오래걸린다는 단점이 있습니다. 그리고 이 과정에서 내부적으로 salt를 생성하고, 이를 해시된 비밀번호에 포함시킵니다.
2. 비밀번호 검증
bcrypt.compare를 사용하여 입력한 비밀번호가 해싱된 비밀번호와 일치하는지 확인합니다. 이 함수에서는 해시된 비밀번호에서 salt를 추출해낸 후, 입력된 비밀번호와 함께 비교해서 검증을 수행하게 됩니다.
bcrypt 라이브러리에선 salt를 따로 관리해줄 필요가 없어 매우 편리합니다. bcrypt외에도 PBKDF2, Argon2, scrypt와 같은 다양한 KDF 알고리즘 라이브러리들이 존재합니다. 특히 Argon2는 2015년 암호학 대회에서 최우수 패스워드 해싱 알고리즘으로 선정된 최신 KDF입니다. 기존 KDF들과 다른 점은 메모리, 시간, 병렬화 비용 등을 개발자가 직접 설정할 수 있어 유연성과 보안성을 동시에 제공한다는 점입니다. 사이드 프로젝트에 꼭 한 번 써봐야겠다는 생각을 했습니다.
글을 마치며
어떤 암호화 라이브러리를 선택할지는 서비스의 특성이나 요구 사항에 따라 달라질 수 있다고 생각합니다.
웹 어플리케이션의 경우, 사용자들의 비밀번호를 안전하게 저장하기 위해 PBKDF2, bcrypt, Argon2와 같은 KDF를 사용하는 것이 좋습니다. 반면 데이터의 무결성 검증이나 디지털 서명이 필요한 서비스라면 SHA-256만으로도 충분하지 않을까 생각합니다. 데이터의 크기가 크고, 암호화 속도가 굉장히 중요한 경우, SHA-256을 쓰는게 경제적인 선택이 될 수도 있겠죠.
하지만 동시에 SHA-256만으로는 매우 빠르게 해시 함수가 계산되기 때문에, 해커가 쉽게 공격할 수 있다는 사실도 명심해야 합니다. 그래서 사용자의 개인정보가 많이 담긴 비밀번호와 같은 경우, 반드시 KDF를 사용하는 것이 좋습니다.