단방향 암호화와 양방향 암호화
crypto는 단방향 암호화 방식이고, bcrypt는 양방향 암호화 방식이다. 단방향은 암호화할 수는 있어도 복호화해서 원래의 비밀번호를 알 수 없고, 양방향은 복호화해서 원래의 비밀번호를 알 수 있다. 그러나, Bcrypt는 Blowfish 알고리즘을 사용하기 때문에 해싱이 느리고 해싱에 엄청난 비용이 든다. 만약 해커가 브루트 포스같은 공격을 해대면 서버에 엄청난 부하가 가해진다.
암호화 복호화
단방향 | 가능 | 불가능 |
양방향 | 가능 | 가능 |
해시(hash)
- 해시(hash) : 해시 함수에 의해 얻어지는 값
- 해시 함수(hash function) : 해시 알고리즘(hash algorithm)이라고도 하며, 임의의 길이의 데이터를 고정된 길이의 데이터로 매핑하는 함수
- 키(key) : 매핑 전 원래 데이터의 값
- 해시 값(hash value) : 매핑 후 데이터의 값
- 해싱(hashing) : 매핑하는 과정
해시 알고리즘
다양한 종류의 해시 알고리즘이 있으며, 알고리즘마다 서로 다른 hash 길이를 가지기도 한다.
그리고 해시 알고리즘은 공개되어 있기 때문에 해커에게도 공개된다. 이 말은 이미 보안이 뚫린 해시 함수가 존재한다는 것이며, 그래서 MD5, SHA-1, HAS-180로 사용해선 안된다고 알려져 있다. 보다 안전한 보안을 위해서 SHA-256, SHA-512 등을 사용하는 것을 권고하고 있다.
crypto 암호화 방식
crypto를 생성할 때는 crypto의 createHash() 메소드를 사용한다.
각 메소드에 대한 인자는 다음과 같습니다.
- createHash() : 해시 알고리즘 중 사용할 알고리즘
- update() : 암호화할 비밀번호(req.body.password등을 사용하여 비밀번호를 가져온 것을 넣어준다)
- digest() : 인코딩 방식
const createHashedPassword = (password) => {
return crypto.createHash("sha512").update(password).digest("base64");
};
이렇게 생성해주면 안되고 salt라는 값을 사용해야 한다. 그 이유는 동일한 해시 알고리즘과 인코딩 방식을 사용할 때 사용자의 비밀번호가 동일한 경우 같은 해시 값을 반환한다. 해커들의 경우 사용자들이 많이 사용하는 비밀번호를 가지고 테이블을 만들어 저장하는데 이를 레인보우 테이블라고 한다. 그래서 해시 알고리즘을 사용하여 비밀번호를 얻어낼 수 있기 때문에 이를 사용한다.
salt
영어를 해석해 보면 소금이라는 의미인데, 랜덤한 값을 의미한다.
- 소금을 뿌리듯이 입력값에 salt라는 값을 붙여준다. 이러한 작업을 salting이라고 한다.
- 해시 함수를 여러 번 돌린다. 이를 Key Stretching이라고 한다.
위의 두가지 단계를 계속적으로 할 수 있다. 입력은 제한이 없지만, 출력인 해시 값은 항상 고정된 길이를 가지므로 다른 입력이지만 같은 해시 값이 나오는 경우도 있다.
비밀번호 암호화하기
- Promisify를 쓰기 전
const createSalt = () =>
new Promise((resolve, reject) => {
crypto.randomBytes(64, (err, buf) => {
if (err) reject(err);
resolve(buf.toString('base64'));
});
});
위의 코드는 솔트를 생성하는 함수이다.
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 });
});
});
해시 알고리즘을 사용하여 암호화하는 작업이다.
const { password, salt } = await createHashedPassword(req.body.user.password);
위의 코드와 같은 작업이 마쳤을 때 변수를 선언하여 db에 넣어준다. 전체적으로 봤을 때 promisify코드를 쓰지 않았을 때 코드가 길어진다.
- Promisify란?
promisify()는 v8.0.0에서 추가된 util모듈의 내장함수이다. 공식문서에 따르면 일반적인 error-first callback style을 따르는 콜백함수를 가져다 promise를 반환하는 함수를 반환한다.
따라서 callback함수를 new Promise로 감싸주지 않아도 promisify()를 통해 간단히 promise로 사용할 수 있으며, 여기서 error-first callback style 함수란 (err, value) => ...의 형태를 가지는, 에러를 가장 먼저 반환하는 스타일의 callback함수를 의미한다.
사용 예시
util.promisify()가 promise를 반환하기 때문에 일반적인 promise를 다루는 법과 같다.
Then, Catch, Finally를 이용하는 방법
const util = require('util');
const fs = require('fs');
const stat = util.promisify(fs.stat);
stat('.').then((stats) => { // Do Something with `stats`
})
.catch((error) => { // error 처리
});
위와 같이 then을 통해 promise를 이용할 수 있고 마찬가지로 async를 이용할 수도 있다.
const util = require('util');
const fs = require('fs');
const stat = util.promisify(fs.stat);
async function callStat() {
const stats = await stat('.');
console.log(`이 디렉토리는 ${stats.uid}의 소유입니다.`);
}
- salt 생성에서 crypto 모듈의 randomBytes(), 비밀번호 암호화 또는 검증에서는 pbkdf2() 메소드를 사용할 것이다.
- Node.js의 내장 모듈인 util의 promisify() 를 사용하면 좀 더 가독성 좋은 코드를 작성할 수 있다.
import util from "util";
import crypto from "crypto";
const randomBytesPromise = util.promisify(crypto.randomBytes); //(1)
const pbkdf2Promise = util.promisify(crypto.pbkdf2);
const createSalt = async () => { // (2)
const buf = await randomBytesPromise(64);
return buf.toString("base64");
};
export const createHashedPassword = async (password) => { // (3)
const salt = await createSalt();
const key = await pbkdf2Promise(password, salt, 104906, 64, "sha512");
const hashedPassword = key.toString("base64");
return { hashedPassword, salt };
};
2. salt 값은 crypto 모듈의 randomBytes() 메소드를 통해 64바이트 길이로 생성한다. buffer 형식을 가지고 있으므로 base64 문자열로 변경하면 랜덤 문자열이 된다.
3. crypto 모듈의 pbkdf2() 메소드를 사용한다. 인자로는 총 5개로 해싱할 값, salt, 해시 함수 반복 횟수, 해시 값 길이, 해시 알고리즘이다. key는 buffer 형식을 가지고 있으므로 base64 문자열로 변경해준다.
- 해시 함수 반복 횟수는 딱 떨어지는 100000보다는 104906와 같은 수를 넣는게 좋기 때문에 이점 유의한다.
추가)
여기서 삽질을 엄청 많이 했다. 위의 코드의 경우는 async함수의 경우는 프로미스 함수를 반환해준다. 그래서 위의 함수들은 다 프로미스 함수를 반환해준다. 이는 비동기 함수를 의미함으로 계속적으로 값을 반환해주기 전까지 돌아간다. 그래서 console.log를 값을 도출하고자 했을 때 Promise { <pending> }값이 계속 나와서 당황했다.
이 곳의 말을 빌리자면 promise함수를 썼다고 해서 직접 값을 바로 도출할 수 있지 않다고 하였다. 그래서 async함수를 썼다면 await를 써서 도출하면 된다. Javascript/node.js에서는 비동기적으로 검색된 값을 가져와 함수에서 직접 반환하는 방법이 없다. promise함수나 콜백 또는 이벤트를 통해 다시 전달해야 한다.
비동기 버전이 존재하는 이유는 함수를 실행하는데 시간이 조금 걸리고 동기 버전을 사용하면 실행되는 동안 이벤트 루프를 차단하기 때문입니다. 정확히 무엇을 하느냐에 따라 이것은 문제가 될 수도 있고 그렇지 않을 수도 있습니다. 이 비동기 버전은 별도의 스레드(libuv 스레드 풀 사용)에서 실제 암호화 작업을 실행하고 이벤트 루프를 차단하지 않도록 값을 비동기적으로 반환합니다.
결론 비동기 함수를 썼을시에 값을 도출할 때도 비동기로 도출해야 한다. 전체적으로 함수가 비동기로 돌아가고 있기 때문이다.
솔루션
전체 프로그램을 자체 실행 비동기 함수로 래핑하고 await키워드를 사용하거나, .then()또는 나머지 코드를 함수에 넣는다.
// promisify 함수 사용
const randomBytesPromise = util.promisify(crypto.randomBytes);
const pbkdf2Promise = util.promisify(crypto.pbkdf2);
// salt 생성
async function createSalt() {
const buf = await randomBytesPromise(64).catch((err) => {
console.log(err);
});
return buf.toString("base64");
}
createSalt().then((result) => {
console.log("result :: " + result);
});
// 해시 알고리즘 사용하여 암호화
(async () => {
async function createHashedPassword(password) {
const salt = await createSalt();
const key = await pbkdf2Promise(password, salt, 104906, 64, "sha512");
const hashedPassword = key.toString("base64");
return { hashedPassword, salt };
}
createSalt().then((result) => {
console.log("result :: " + result);
});
const { hashedPassword, salt } = await createHashedPassword(password);
console.log("hashedPassword :: " + hashedPassword);
console.log("salt :: " + salt);
models.Users.create({
join_id: req.body.join_id,
password: hashedPassword,
name: req.body.name,
address: req.body.address,
phone: req.body.phone,
email: req.body.email,
salt: salt
})
})();
→ 내 경우 salt를 생성할 때는 then을 사용하여 salt가 생성되었는지 확인하였고, 두번째로 해시 알고리즘을 사용하여 암호화할 때는 비동기 함수로 전체를 랩핑하고 await키워드를 사용하여 변수를 선언해주었다. 그렇지만 이 함수가 다가 아니라 아래에 이메일과 휴대폰 유효성 검사 때문에 코드가 길어졌다. 그래서 db를 선언해주는 코드가 아래에 있어서 then을 사용해줄 수 없어 모델 선언해주는 곳까지 코드를 랩핑해주었다.