해시 함수(Hash Function)

임의의 길이의 데이터를 고정된 길이의 데이터로 매핑하는 함수이다. 해시 함수에 의해 얻어지는 값은 해시 값, 해시 코드, 해시 체크섬 또는 간단하게 해시라고 한다. - 위키피디아 해시 함수 문서 해시 값을 다이제스트(Digest, 요약)라고도 합니다.

임의의 길이의 데이터를 고정된 길이의 데이터로 매핑한다는 것은, 입력된 데이터의 길이가 1이든 10이든 100이든 해시 함수에서 정한 고정된 길이로 해시의 결과를 얻을 수 있다는 의미입니다. 해시 함수는 입력값 -> 해시 로의 변환은 가능하지만 해시 -> 입력값으로의 복구는 어려운 특징이 있으며, 이러한 특징으로 원본을 숨기기 위해서 서비스의 사용자 비밀번호를 해시하여 저장하기도 하고, 블록체인에도 활용되며, 파일의 위/변조를 확인하기 위해서, 해시 테이블이라는 자료구조에서 활용되는 등 사실상 “해시”의 개념이 필요한 곳이라면 어디든 쓰일 수 있습니다.

<파일 다운로드 시 변조 여부 예시 - 출처> Windows 7 다운로드 화면의 해시

간단한 해시 함수 예시

잘 만들어진 해시 함수들이 많지만, 간단히 해시 함수를 이해하기 위한 예시를 들어보겠습니다. 만들어볼 해시 함수는 sum-hash라고 이름 붙여보고, 아래와 같은 특징을 갖도록 구현해 봅시다.

  1. 입력과 출력은 문자열 타입
  2. 입력 받은 문자열의 각 글자의 코드 값을 합한 값을 해시로 사용합니다.
  3. sum-hash의 해시된 결과 길이는 4자입니다.
  4. 2의 과정에서 합한 값의 자리수가 4보다 적은 경우 출력 문자열의 앞에 모자란만큼 0을 채웁니다.
  5. 2의 과정에서 합한 값의 자리수가 4보다 큰 경우 출력 문자열의 끝에서 4자리를 넘는 문자를 제거합니다.

이를 자바스크립트로 만들어보면 아래 정도로 만들어 볼 수 있습니다

function sumHash(input) {
    let sum = 0;
    for(let i = 0 ; i < input.length ; i++) {
        sum += input.charCodeAt(i);
    }

    let hash = sum + ''; // 문자열로 변환
    if(hash.length < 4) {
        // 글자 수가 4자보다 작은 경우 0을 붙여줍니다.
        hash = '0'.repeat(4 - hash.length) + hash;
    } else {
        // 그 외의 경우 0번째~3번쨰 글자만 잘라냄
        hash = hash.slice(0, 4);
    }

    return hash;
}

특징을 잘 구현했다면, 입력의 길이에 상관없이 항상 4자의 해시 값을 얻을 수 있습니다.

console.log(sumHash('1'));
// 1 -> 0049

console.log(sumHash('안녕하세요'));
// 안녕하세요 -> 2508

console.log(sumHash('Into the unknown'))
// Into the unknown -> 1579

sum-hash는 해시로 변환은 가능하지만 해시에서 원래의 입력 값을 찾는 것은 어렵습니다.

console.log(sumHash('abcd')); // 0394
console.log(sumHash('dcba')); // 0394
console.log(sumHash('bbcc')); // 0394

위의 입력뿐만 아니라 다른 입력 값에 대해서도 얼마든지 해시 값은 0394가 될 수 있습니다. 이런 경우 0394가 될 수 있는 입력 값들을 여러 개 찾더라도 그 중 어떤 값이 원래의 입력이었는지는 특정하기 어렵습니다. 하지만 반대로 sumHash를 비밀번호를 저장할 때 적용했다면, 'abcd'를 입력하든 'dcba'를 입력하든 비밀번호가 일치한 것으로 판단하게 되므로 비밀번호 저장에는 적합하지 않은 것으로 볼 수 있겠죠.

참고로 sumHash는 해시 함수의 특성을 보기 위한 예시입니다. 실제로 사용하기에는 매우 위험하고 비효율적인 해시 함수입니다.

이렇게 서로 다른 입력에 대해 같은 해시가 출력되는 것을 해시 충돌(Hash Collision)이라고 합니다. 해시 함수의 특성상 결과는 고정된 길이를 가지므로 발생하는 현상입니다. 아파트와 입주민을 예로 들어본다면, 아파트(고정된 자리수)가 10채 있고, 사람(입력의 자리수)이 10명 이하일 때 빈 집에만 들어간다면 2명이 함께 있는 아파트는 없을 것이지만, 만약 10명을 초과하면 어느 아파트에는 적어도 2명 이상의 사람이 있을 수 밖에 없고, 이를 해시 충돌로 볼 수 있습니다. (아파트 대신 비둘기 집, 사람 대신 비둘기로 표현한 원리를 비둘기집 원리라고 합니다.)

암호화와 해시

암호화와 해시는 입력이 변환된 결과를 출력하는 것은 동일하지만, 해시는 입력->출력으로만 가능하고 출력->입력으로는 불가능하며, 암호화에서는 입력->출력, 출력->입력 양방향 모두 가능합니다. 암호화에서 출력->입력을 복호화(Decryption)이라고 합니다. 정리하면 해시는 단방향, 암호화는 양방향의 특징을 갖습니다.

해시의 보안 향상하기

해시 자체로도 어느 정도의 보안이 보장되지만, 이를 조금 더 안전하게 해싱하기 위해서 해시 함수를 여러번 적용하는 스트레칭(Stretching)Salt가 있습니다. Salt는 공격자가 원본을 더 찾기 어렵도록 임의의 값(Salt 값)을 원래의 입력값에 붙여서 해싱하는 것을 말합니다. 따라서 Salt 값은 노출되지 않도록 주의를 기울여야 합니다.

Strech 예시

let hash = 'hello';

for(let i = 0 ; i < 10000 ; i++) {
    // hash 변수의 값을 해싱한 후 다시 hash 변수에 저장
    hash = sumHash(hash);
}

console.log(hash); // 0208 출력

Salt 예시

let password = 'hello';
let salt = '&^%765D01=)fI@(f9'; // 무의미한 값

// password와 salt를 더해서 함께 해시 - 1568 출력
console.log(sumHash(password + salt));

bcrypt와 bcryptjs

bcrypt는 비밀번호를 해싱하기 위해 고안된 해시 함수입니다. bcrypt를 적용한 해시 값은 $2a$, $2b$ 또는 $2y$로 시작합니다. 이를 보고 bcrypt 해시 함수를 사용할 수 있음을 알 수 있고, 128비트의 Salt를 필요로하며, 결과 해시 값의 길이는 184 비트입니다. 이 결과를 RADIX-64로 인코딩하여 출력합니다.

bcrypt 해시 함수는 자바스크립트에서 bcryptjs로 구현되어 있습니다.

npm을 이용한 bcryptjs 다운로드

npm 이 설치되어 있는 환경에서는 아래와 같이 bcryptjs를 다운로드 할 수 있습니다.

npm install bcrypt

bcryptjs로 해시하기

npm을 이용해 bcrypt를 설치한 후 쉘에서 node를 실행해서 REPL 모드에서 bcrypt를 사용해 보겠습니다. 자세한 사용 방법은 공식 문서를 참고해 주세요.

// bcryptjs 모듈 로드
const bcrypt = require('bcryptjs');

// Salt 생성(Generate)
// genSaltSync를 호출할 때 전달한 인자 10은 Salt를 생성하기 위해 반복한 횟수입니다.
// 내부에서 무작위 값을 사용하기 때문에 매번 다른 결과가 표시되는 것을 볼 수 있습니다.
let salt = bcrypt.genSaltSync(10);
console.log(salt); // 제 경우에는 '$2a$10$1x3od0H2aKtDX4AAgBhiCe'이 생성되었습니다.

let password = 'gardenist';

// password를 salt와 함께 bcrypt로 해싱합니다.
let hash = bcrypt.hashSync(password, salt);
// $2a$10$1x3od0H2aKtDX4AAgBhiCe7a5.gZcJGmEGovJzHpZX9nNDEFFUTR. 출력
// 위 값은 salt 값에 따라 달라집니다.
console.log(hash);

// 입력한 비밀번호와 해시가 일치하는지 확인
bcrypt.compareSync(password, hash); // true
bcrypt.compareSync('잘못된 password', hash); // false

스터디 때 잠깐 다룰 때는 웹 브라우저에서 bcrypt를 사용했지만, 보통은 서버 애플리케이션 안에서 Salt를 생성하고, 비밀번호 일치 여부를 판단합니다. 보안과 관련된 내용은 웹 브라우저에 노출되기보다 서버 애플리케이션에서 다루는 것이 안전한 방법입니다. 이 후에 Node.js에서 다시 bcryptjs를 다뤄보도록 하겠습니다.

References

  • https://en.wikipedia.org/wiki/Bcrypt
  • https://ko.wikipedia.org/wiki/해시_함수
  • https://ko.wikipedia.org/wiki/해시_충돌
  • https://d2.naver.com/helloworld/318732
  • http://www.itworld.co.kr/news/94202
  • 한빛미디어 - 처음 배우는 암호화(Serious Cryptography), Jean-Philippe Aumasson 저, 류광 옮김