제목 : 비밀번호는 왜 해싱할까? - With Node.js
웹 어플리케이션 서버(BE) 를 만들 때,
단순한 엔드포인트 제작에 대한 것을 배우고 나서 회원가입 처리를 배운다.
데이터베이스에 Plain Text 로 비밀번호를 저장한다면, 보안상 굉장히 위험하므로,
Encryption 과정을 거친 후, 데이터베이스에 저장한다.
이 때, Encryption 과정은 양방향이 아닌, 단방향 해싱이라고 배운다.
따라서, 관리자조차 원래의 비밀번호를 알지 못하기 때문에, 유저는 직접 비밀번호를 바꾸어야 한다.
위의 과정을 듣고 나서 대부분의 사이트들이 왜 비밀번호를 찾을 때, 원래의 비밀번호를 알려주지 않고
새로운 비밀번호로 지정하게 하는지 이해되었다.
그러나, 위의 과정만 필요하다면 이 포스팅은 시작하지는 않았을 것이다.
푸는 데 꽤 오랜 시간이 걸리는 암호화 과정에 왜 Salt
(소금??) 라는 개념이 필요한가?
여기에서 시작했다.
단어 자체에 집중 해 보다.
나는 의외로 "소금" 이라는 단어에 집중했다.
충분히 버무려졌을 법한 해싱 문자열에 "소금" 은 왜 뿌려야 하는가?
분명히 보안이라는 목적에 충실하기에는 조금 부족하지 않았기에 추가되었을 것이라는 가정을 세웠다.
무엇이 보안이라는 목적에 조금 부족했는가?
단방향 암호화 해시 함수는 단방향 작용으로, 결과물을 보고 역산할 수 없다.
그런데, 여기서 문제점이 생긴다.
대신에 모든 단방향 해싱 결과를 "미리 기록해놓은" 레인보우 테이블에 취약하다는 것이다.
레인보우 테이블이란?
사람들이 매우 자주 사용하는 비밀번호에 대해 해싱된 결과를 리스팅 한 것이 레인보우 테이블이다.
문제는, 이것이 수백만 개를 가지고 있는 사람은, 단순 암호화가 된 비밀번호를 즉각적으로 알아낼 수도 있다는 것이다.
이러한 문제로 인해, Salt
즉, 소금이라는 개념이 생기게 되었다.
단방향 해싱 함수는 단 하나의 문자열만 추가되거나 다른 것이라면,
거의 어떠한 공통점도 찾아볼 수 없을 정도로 달라진다.
그러나, 이러한 특징도 결국 결과물을 대조하는 레인보우 테이블에 취약하다.
이는 유저들이 특수 기호를 많이 사용하지 않고, 일반 알파벳을 주로 넣는 습성때문에 어쩔 수 없는 것일 거다.
그렇다면, 서버에서 자체적으로 생성한 "추가 비밀번호" 를 붙여주면 어떨까?
그것도 외부 엔트로피로 인해 예측할 수 없는 랜덤 비밀번호를 붙여준다면?
레인보우 테이블은 더 이상 유저의 비밀번호를 대조할 수 없을 것이다.
위에서 말한 "자체적으로 생성한 추가 비밀번호" 가 바로 salt
로 비유할 수 있다.
유저들이 알파벳을 주로 사용하고, 자주 겹치기에 레인보우 테이블에 약하다면,
서버는 특수기호가 덕지덕지 붙은 salt
를 추가하여 비밀번호를 만들어 주면 될 것이다.
이 글을 포스팅하기 이전엔 salt
자체를 환경 변수로 넣어서 비밀번호를 생성했는데,
해킹하기로 마음먹고 무작위로 비밀번호를 지속적으로 대입한다면,
생성된 해시 문자열로부터 salt
자체도 파훼될 수도 있겠다는 생각이 들었다.
그렇다면 어떻게 salt
를 관리해야 할까?
유명한 라이브러리 bcryptjs
의 경우
bcrypt 는 자체적으로 salt
를 생성하여 문자열에 포함시킨다.
bcrypt 는 "라운드" 를 지정하여 최종 해싱 문자열을 데이터베이스에 저장한다.
NodeJS 라이브러리 scrypt
의 경우
scrypt 의 경우, 라운드 개념이 없고, 내부 파라미터들을 자체적으로 조합하여 KEY 를 만든다.
물론 임의의 값으로 배정된다.
salt
는 자체적으로 생성 해 주어야 하며, 각 비밀번호 마다 고유하다(Unique).
그러므로, 사용자마다 salt
를 저장 해 주어야 한다.
- 해싱 문자열 결과물에
salt
를 덧붙인다. - 혹은 데이터베이스 정규화를 통해
salt
를 따로 저장한다. - 원하는 다른 방법으로 저장한다.
bcrypt 예시
비밀번호 생성
import * as bcrypt from "bcryptjs";
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync("비밀번호", salt);
// 해싱된 비밀번호 == hash 를 데이터베이스에 입력
비밀번호 비교
import * as bcrypt from "bcryptjs"
const comparePassword = "비밀번호";
const hashedPassword = "?????"; // 정체를 모르는 다채로운 문자들 예시
bcrypt.compare("비밀번호", hashedPassword, (err, res) => {
// 비밀번호가 같다면, 여기서 처리
// 함수 내부에서 사용 할 거면, await bcrypt.compare(..) 로 사용하는 것이 편하다
})
scrypt 예시
비밀번호 생성
import {randomBytes, scrypt, scryptSync} from 'node:crypto';
// randomBytes 는 여러 외부 엔트로피 요인과 결합되어 랜덤한 데이터를 배출한다.
const salt = randomBytes(8).toString("hex");
// 방법 1
scrypt("비밀번호", salt, 64, (err, derivedKey) => {
if(err)
throw err;
console.log(derivedKey.toString("hex"));
const resultPassword = derivedKey.toString("hex") + "." + salt;
// 비밀번호 저장
});
// 방법 2
const hash = scryptSync("비밀번호", salt, 64).toString("hex");
console.log(hash);
const resultPassword = derivedKey.toString("hex") + "." + salt;
// 비밀번호 저장
비밀번호 비교
import {scrypt} from "node:crypto"
const password = "비교될 비밀번호";
const hashedPwd = "?????????.같이 저장되었던 salt"
// 뒤에 저장된 캐시 문자열만 가져오기
const salt = hashedPwd.split(".")[1];
const pwdHash : string = scryptSync("비교될 비밀번호", salt, 64).toString("hex");
// 동일한 문자열 데이터라면 true, 아니라면 false.
const isSame : boolean = pwdHash === hashedPwd
bcrypt 방식과, node 엔진에 탑재된 scrypt 방식은 조금 다르다. 이에 대해 알아보자!
bcrypt(bcryptjs) 에 대한 설명
bcrypt
방식은 scrypt
방식보다는 생성과 비교가 매우 간편하다.
salt
도 자동으로 생성해주고, 해싱된 패스워드 내부에 암호화 알고리즘과 salt
가 들어 있다.
여기서 지정하는 round
는 $ 2^N $ 에서 N
을 의미한다. 해당 수 만큼 라운딩 된 결과를 내뱉는다는 것이다.
그리고, bcrypt
는 내부에 암호화 알고리즘과 salt
를 동봉하기 때문에,
제공하는 메서드로 곧바로 결과를 알 수 있다.
nodejs 의 crypto 라이브러리에 대한 설명
bcrypt
보다는 조금 low 한 방식이다. 그래도 이것도 편한 방식이다.
node:crypto
라는 엔진에 탑재된 라이브러리에서 ramdomBytes
, scrypt
메서드를 가져온다.
두 메서드 모두, Buffer
라는 데이터 배열을 반환한다.
이 때문에, 꼭 toString("hex")
를 통해 문자열 변환을 해 주어야 한다.
그리고, bcrypt
처럼 자체적인 compare
메서드는 없기 때문에,
사용자가 저장해 둔 salt
를 다시 가져와서, 비교 할 패스워드를 동일하게 해싱한다.
저장된 해싱 비밀번호와, 비교할 해싱 비밀번호가 동일하면, 입력된 비밀번호가 동일하다는 이야기이다.
배운 것
프로젝트를 시작하고, 백엔드 어플리케이션을 구현한 기억이 난다.
그 때에는 "빠르게 구현" 이라는 키워드에 붙잡혔기 때문에,
팀원이 bcrypt
로 사용하라는 의견에 동참하여 가져다 썼다. 동작하기만 하면 되었기 때문이다.
하지만, 마음속에서는 bcrypt 는 어떻게 비밀번호를 바로 검증하지? 라는 의문이 들었었다.
왜냐면 salt
를 데이터베이스에 저장하는 로직이 없었기 때문이다.
bcrypt
의 최종 해싱 비밀번호의 형태는
$[알고리즘]$[비용]$[salt][hash]
이다.
즉, 이미 bcrypt 는 비밀번호를 생성 할 때, 추후 비교를 위해서
salt
를 비밀번호 안에 넣어놨었던 것이다.
그렇기 때문에, compare
메서드 구현시 salt
를 가져올 필요가 없었다.
요즘에 들어서야 항상 궁금한 것이 생기면, 누군가도 이것을 궁금할 것이라고 생각하여 블로그 글을 쓴다.
단순하게 배운 것이 아니라, 이해하는 것이 제일 중요하다고 생각한다.
포스팅 할 때 오래 걸리는 주제도 있고, 하루가 걸리는 주제도 있지만,
항상 느끼는 것은, 내가 발전하고 있다는 것이다.
개발자가 아닌, 개발 엔지니어가 되도록 항상 노력해야 한다.
참고 사이트
https://nodejs.org/api/crypto.html#nodecrypto-module-methods-and-properties
https://nodejs.org/api/crypto.html#cryptorandombytessize-callback
https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
'Node.js > 잡다 지식' 카테고리의 다른 글
.http 파일과 httpyac (1) | 2025.05.07 |
---|---|
Jest 와 유닛 테스트 (4) | 2025.05.05 |
NestJS 의 Interceptor 는 무엇일까? - (NestInterceptor) (0) | 2025.04.12 |
WebAssembly 와 Node.js (0) | 2025.04.08 |
node.js 로 멀티 스레드 구현하기 (Worker) (0) | 2025.04.07 |