[Node.js] 안전한 암호화 기법인 scrypt 로 사용자 패스워드 암호화하기

들어가기 전에

패스워드는 암호화되어 저장되어 있어야 합니다. 법적으로도 그렇고 보안적으로도 당연합니다. 당신의 데이터이스는 언제든지 털릴 수 있습니다 … scrypt 함수를 이용하는데, 이게 콜백을 받는 함수라서 Promise 로 래핑했습니다. Promise 에 대한 기본 지식이 있어야 코드를 이해할 수 있습니다. mongoose 코드도 많이 사용하므로, 해당 라이브러리를 알고 있다면 읽으시는 데 크게 도움이 될 겁니다.

내가 이해한 암호화

필자는 암호화에 대해서 잘 모릅니다. 하지만 대략적으로라도 이해하고 있어야 왜 scrypt 를 쓰는지 스스로 납득을 할 수 있기 때문에, 최소한의 수준으로 설명하고자 합니다.

암호화를 하는 이유는 해당 정보가 중요하기 때문입니다. 물론 기본적으로 데이터베이스가 털릴 일을 만들지 않는 것이 베스트이겠지만, 만에 하나 데이터베이스가 털렸다 하더라도 내용을 알 수 없게 암호화를 해놓는 것입니다.

암호화에는 여러가지 방법이 있을 수 있습니다. 그 중 간단한 해쉬 함수를 이용하는 방법을 봅시다. 해쉬 함수란 간단히 이야기하여 같은 입력 값에 같은 출력값이 나오는 게 보장되지만, 출력 값으로 입력 값을 유추할 수 없는 것을 의미합니다. 아래는 단순한 예시입니다. 출력 값으로 입력 값을 알 수가 없겠죠?

입력값출력값
a35cbefc0691a5ebd7337dec23414baeb75ab6ecc33e78354d3adaf1e4923f8aa
bc47340d0011ba756c6264b2be5071fea99dc8f1213163ae7383b902811a60163
c446474b819d6debddd55d0944e3912f33fe97bbcf6d1e5005130bb428dd1d36c
d9d0f1dbef956ff0d418043acc57f6330fc4521c89053921f3668a7f805c2d39e

이렇게 입력 값으로 사용자가 쓴 비밀번호를 넘겨서 나온 출력 값을 실제 데이터베이스에 저장해놓는다면, 실제로 데이터베이스가 노출되어도 본래 입력 값을 알 수 없으니 보안에 안전해 보입니다.

하지만 안전하지 않을 수 있다는 점… 보통의 해쉬 함수는 속도가 중요하기 때문에, 충분히 빠릅니다. 보안에서는 이 빠른 게 오히려 공격자에게 좋습니다. 왜냐하면 가능한 모든 입력 값에 대한 출력 값을 정리해놓고, 정리해 놓은 출력 값과 실제 데이터베이스에 저장되어 있는 값을 일일히 비교하는 작업이 가능하기 때문입니다. 해쉬 함수의 성능에 따라 1초에 몇억 번을 수행할 수 있다고 하니, 입력 값을 몇백 억, 몇 조 단위까지 정리해 놓을 수 있다면 비밀번호를 뚫는 게 불가능해보이지가 않지요. 이렇게 입력값-출력값을 저장해 놓는 것을 레인보우 테이블(Rainbow Table) 이라고 합니다. (레인보우 테이블은 공간 효율을 위해 실제로 입력 값과 출력 값이 1:1로 구성되어 있는 건 아닙니다. 자세한 정보를 알고 싶다면 위키백과(영어)를 참조해주세요.)

자 그렇다면 레인보우 테이블이 쓸모없게 만들어버리거나, 아예 레인보우 테이블을 만들기 불가능하도록 하면 되겠네요.

첫번째로 salt 라는 기법이 있습니다. 이는 입력으로 들어가는 비밀번호에 추가 문자열을 덧붙입니다. 그래서 A 유저와 B 유저의 비밀번호가 같다 하더라도 같은 해쉬 출력 값을 가지고 있지 않도록 합니다. 만약 A 유저의 비밀번호가 털렸다 하더라도 B는 아직 안전한 셈이죠. salt 값은 암호화 최초에 설정하되, 항상 비밀번호에 매번 같은 salt 가 추가되어야 출력 값도 같으므로 salt 값을 어디엔가 잘 가지고 있어야 합니다.

솔트와 패스워드의 다이제스트를 데이터베이스에 저장하고, 사용자가 로그인할 때 입력한 패스워드를 해시하여 일치 여부를 확인할 수 있다. 이 방법을 사용할 때에는 모든 패스워드가 고유의 솔트를 갖고 솔트의 길이는 32바이트 이상이어야 솔트와 다이제스트를 추측하기 어렵다.

Naver D2 Helloworld – 안전한 패스워드 저장
유저비밀번호입력값(비밀번호 + salt)출력값
A12341234_a_user_salt9d0f1dbef956ff0d418043acc57f6330fc4521c89053921f3668a7f805c2d39e
B12341234_b_hello_world446474b819d6debddd55d0944e3912f33fe97bbcf6d1e5005130bb428dd1d36c

두번째로 출력 값을 아주 느리게 산출되도록 하는 방법입니다. 기껏해야 1초에 5번 출력값을 계산해야 할 만큼 계산량이 많다면 레인보우 테이블을 제작하는 데도 어마어마한 시간과 비용이 들어갈 것입니다. 여기까지 잘 구현된 게 bcrypt 입니다. 하지만 단순히 계산을 느리게 만들었다고 해서 레인보우 테이블을 만드는 것이 불가능해지는 건 아닙니다. 만약 그래픽카드를 쓴다면요? 코인도 잘근잘근 씹어먹는 엄청난 병렬 연산으로 단숨에 계산해버릴 수도 있습니다!

이제 이러한 점도 보완한 세 번째를 알아봅시다. CPU 연산 뿐만 아니라 어느 정도 많은 메모리를 써야 해싱이 가능하도록 하는 것입니다. 그래픽카드는 메모리가 충분한 건 아니기 때문에 이제 그래픽카드로 할 수가 없게 됩니다. 여기까지 구현해놓은 것이 scrypt 입니다.

crypto.scrypt(password, salt, keylen[, options], callback)

scrypt 함수의 정의입니다.

구현

/**
 * 암호화된 비밀번호 객체
 * @typedef {Object} Encrypted
 * @property {String} pwd
 * @property {String} salt
 */

/**
 * 비밀번호 암호화 함수. 평문을 암호화하여 저장함.
 * @param {string} plain 평문
 * @returns {Promise<Encrypted>} 암호화된 base64 기반 암호.
 */
const pwd_encrypt = async (plain) =>
  new Promise((resolve, reject) => {
    const salt = crypto.randomBytes(64).toString('base64');
    let result = '';
    crypto.scrypt(plain, salt, 64, (err, derivedKey) => {
      if (err) return reject(err);
      result = derivedKey.toString('base64');
      return resolve({ pwd: result, salt });
    });
  });

/**
 * 로그인 정보만 db에 기록합니다. 유저(User)는 일체 건드리지 않습니다.
 * 반드시 다른 기능과 조합되어야 합니다!
 * @param {string} email 해당 이메일
 * @param {string} plainPwd 암호화되기 전
 */
async upsertOnlyLogin(email, plainPwd) {
  if (typeof email !== 'string' || typeof plainPwd !== 'string') {
    throw Error('upsertOnlyLogin: 인수가 잘못되었습니다.');
  }
  // 패스워드 정보는 유일해야 하므로 관련된건 전부 삭제함.
  await model.Login.deleteMany({ email });

  const { pwd: pwdEncrypted, salt } = await pwd_encrypt(plainPwd);
  await model.Login.create({ email, pwd: pwdEncrypted, salt });
}

/**
 * 비밀번호가 맞는지 체크합니다.
 * @param {string} given
 * @param {Encrypted} encrypted
 * @returns {Promise<boolean>} 맞으면 true, 틀리면 false
 */

const pwd_verify = async (given, encrypted) =>
  new Promise((resolve, reject) => {
    let result = '';
    crypto.scrypt(given, encrypted.salt, 64, (err, derivedKey) => {
      if (err) return reject(err);
      result = derivedKey.toString('base64');
      return resolve(result === encrypted.pwd);
    });
  });
  1. 누군가가 회원가입을 하려고 합니다.
  2. upsertOnlyLogin 함수를 호출합니다. 이 함수의 목표는 로그인 정보를 데이터베이스에 기입하는 것입니다.
  3. pwd_encrypt 함수를 호출합니다. 이 함수는 랜덤으로 salt 를 생성하고, 해당 salt로 평문 비밀번호(plainPwd)를 암호화된 비밀번호(pwd: pwdEncrypted)로 바꾸며, 해당 salt 와 암호화된 pwd 를 리턴하는 함수입니다. 앞서 말했듯 salt 는 따로 잘 저장하고 있어야 합니다.
    1. salt는 랜덤 64바이트 데이터입니다.
    2. 암호화의 결과는 모두 Buffer (단순 바이트 정보의 나열) 이므로 이를 모두 적절한 문자열로 바꾸어줍니다. .toString('base64')로요. 바꾸어주지 않으면 사람이 읽기가 불가능해지는 매우 반항아적인 데이터가 될 것이고, 데이터베이스에 저장이 되지 않을 수도 있습니다.
    3. crypto.scrypt 는 용도에 따라 적절하게 호출합니다.

  1. 추후 비번이 맞는지 체크할 때 pwd_verify 함수가 호출됩니다.
  2. 똑같은 과정으로 scrypt 를 실시하여, 그 결과값을 비교합니다.

마치며

보안 필수!

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다

Scroll to top