비밀번호를 있는 그대로 데이터베이스에 저장하는 개발자는 테러리스트와 같다. 로컬 환경에서 테스트할 목적이라면 모르겠지만, 외부에 배포되는 순간 회원가입 로직이 있다면 무조건 암호화할 의무가 있다. 물론 데이터베이스가 안 뚫리는 것이 가장 이상적이지만, 생명보험을 들어놓는 것과 같다.

단방향 암호화와 양방향 암호화

간단하게 설명하자면 단방향은 암호화할 수는 있어도 복호화해서 원래의 비밀번호를 알 수 없고, 양방향은 복호화해서 원래의 비밀번호를 알 수 있다. 대부분의 사이트는 비밀번호를 찾을 때 원래의 비밀번호를 알려주는 것이 아닌 재설정한다. 그렇다. 굳이 복호화할 이유가 없다.

단방향 암호화는 Hash 알고리즘을 사용한다. 임의의 문자열을 고정된 길이의 다른 문자열로 변경하는 것이다. 비밀번호가 123, 123456으로 길이가 달라도 Hash 알고리즘에서 길이를 5로 설정했다면 비밀번호는 abfe1, bf3sj처럼 5글자로 변경된다.

Crypto vs Bcrypt

Crypto 관련 글을 검색하다가 Bcrypt라는 모듈도 있다는 것을 알게 되었다. 그러나 Bcrypt는 Blowfish 알고리즘을 사용하기 때문에 해싱에 엄청난 비용이 든다고 한다. 만약 해커가 브루트 포스같은 공격을 해대면 뚫리지는 않을지라도 서버에 엄청난 부하가 가해진다.

Crypto 모듈이 Node 기본 모듈로 들어간 이유가 다 있다고 생각하고 순수하게 Crypto만을 사용해서 바람직한 암호화를 하기로 했다.

해서는 안될 암호화

1
2
3
4
5
6
const crypto = require('crypto');
const base64crypto = password => {
console.log(crypto.createHash('sha512').update(password).digest('base64'))
base64crypto('1234')
base64crypto('1234')
}

위에서 작성한 base64crypto 함수는 sha-512 알고리즘으로 해싱한 암호화된 문자열을 뱉어주지만 서로 다른 유저가 ‘1234’, ‘1234’ 비밀번호로 회원가입을 했다고 가정하면, 둘의 암호화된 비밀번호가 같아진다. 해커는 이를 통해 비밀번호를 유추할 수 있다. (이를 찾은 문자열의 목록을 레인보우 테이블이라고 한다)

Salt 암호화

보안에 완벽이라고 단언할 암호화는 없지만 현재로서는 가장 안전하다고 여겨지는 Salting과 Key Stretching을 이용하여 강력한 암호화를 할 수 있다.

Salting은 말 그대로 Salt, 소금을 뿌리는 것이다. 기존의 문자열에 salt를 붙여 새로운 문자열을 반환한다.

Key Stretching은 기존 문자열의 다이제스트를 생성하고 생성된 다이제스트로 다시 다이제스트를 생성한다. 키 스트레칭을 999번한 비밀번호와 1000번한 비밀번호는 생김새가 완전히 다르다.

우선 암호화가 필요한 테이블에 salt 컬럼을 추가하자. 비밀번호와 별도로 같이 저장될 랜덤 문자열이고, 로그인 시 password 컬럼과 salt 컬럼을 통해서 유저가 입력한 암호를 다시 암호화하는 로직이다.

회원가입에 적용하기

꽤 괜찮다고 생각되는 함수를 작성해보았다.

createSalt
1
2
3
4
5
6
7
const createSalt = () =>
new Promise((resolve, reject) => {
crypto.randomBytes(64, (err, buf) => {
if (err) reject(err);
resolve(buf.toString('base64'));
});
});

우선 Crypto 모듈의 randomBytes 메소드를 통해 Salt를 반환하는 함수를 작성한다.

createHashedPassword
1
2
3
4
5
6
7
8
const createHashedPassword = (plainPassword) =>
new Promise(async (resolve, reject) => {
const salt = await createSalt();
crypto.pbkdf2(plainPassword, salt, 9999, 64, 'sha512', (err, key) => {
if (err) reject(err);
resolve({ password: key.toString('base64'), salt });
});
});

이제 암호화가 안된 비밀번호를 인자로 받아 위에서 작성한 createSalt 함수로 salt를 생성하고 sha-512로 해싱한 암호화된 비밀번호가 생성된다. 이 함수는 passwordsalt 모두를 반환하고 데이터베이스에 둘 다 넣어주면 된다. 키 스트레칭은 9999로 해놓았는데 딱 맞아 떨어지는 숫자말고 적당히 큰 수를 넣어줘도 상관없다.

예를 들어,

1
const { password, salt } = await createHashedPassword(req.body.user.password);

이런식으로 암호화된 비밀번호와 salt를 생성해서 가져온 후,

1
2
3
4
5
6
await models.user
.create({
...req.body.user,
password,
salt,
})

이렇게 DB에 넣어주면 된다.

회원가입 로직은 이렇게 구현하면 끝이다.

makePasswordHashed

이제 로그인 로직이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const makePasswordHashed = (userId, plainPassword) =>
new Promise(async (resolve, reject) => {
// salt를 가져오는 부분은 각자의 DB에 따라 수정
const salt = await models.user
.findOne({
attributes: ['salt'],
raw: true,
where: {
userId,
},
})
.then((result) => result.salt);
crypto.pbkdf2(plainPassword, salt, 9999, 64, 'sha512', (err, key) => {
if (err) reject(err);
resolve(key.toString('base64'));
});
});

비밀번호와 유저 ID를 인자로 받아 패스워드를 암호화한다. 단방향 암호화 방식이기 때문에 유저가 보낸 Plain Password를 위에서 한 방식대로 그대로 암호화해서 비교하면 된다.

여기서 다른 것은 회원가입에서는 salt를 랜덤 문자열로 만들고, 로그인에서는 회원가입에서 만들어진 salt를 가져와서 해싱하는 것이다. 암호화 방식이 똑같기 때문에 키 스트레칭이나 해싱 알고리즘이 서로 다르면 안된다.

함수를 사용하는 예를 들면,

1
2
const { userId, password: plainPassword } = req.body.user;
const password = await makePasswordHashed(userId, plainPassword);

나는 이런식으로 사용한다. 그러면 password에는 비교할 암호화된 문자열이 만들어질 것이고, 각자 사용하는 데이터베이스에서 유저의 ID와 password를 비교해주면 된다.