...
암호화 종류
암호화를 하는 이유는 해당 정보가 중요하기 때문이다.
암호화에는 여러가지 방법이 있을 수 있다.
대표적으로 단반향과 양방향 암호 방식이 있는데,
단방향은 암호화할 수는 있어도 복호화해서 원래의 비밀번호를 알 수 없고, 양방향은 복호화해서 원래의 비밀번호를 알 수 있다.
또한, 양방향 암호화는 대칭형 암호화와 비대칭형 암호화가 존재한다.
대칭형 암호화와 비대칭형 암호화는 암호화할 때 사용하는 키와 복호화할 때 사용하는 키의 동일성에 대한 기준으로 구분하게 된다.
단방향 암호화 방법
그 중 간단한 해쉬 함수를 이용하는 방법을 보자.
해쉬 함수란 간단히 이야기하여 같은 입력 값에 같은 출력값이 나오는 게 보장되지만, 출력 값으로 입력 값을 유추할 수 없는 것을 의미한다.
아래는 단순한 예시 이다.
출력 값으로 입력 값을 알 수가 없게 된다.
입력값 | 출력값 |
a | 35cbefc0691a5ebd7337dec23414baeb75ab6ecc33e78354d3adaf1e4923f8aa |
b | c47340d0011ba756c6264b2be5071fea99dc8f1213163ae7383b902811a60163 |
c | 446474b819d6debddd55d0944e3912f33fe97bbcf6d1e5005130bb428dd1d36c |
d | 9d0f1dbef956ff0d418043acc57f6330fc4521c89053921f3668a7f805c2d39e |
이렇게 입력 값으로 사용자가 쓴 비밀번호를 넘겨서 나온 출력 값을 실제 데이터베이스에 저장해놓는다면, 실제로 데이터베이스가 노출되어도 본래 입력 값을 알 수 없으니 보안에 안전해 보인다.
대부분의 사이트는 비밀번호를 찾을 때 원래의 비밀번호를 알려주는 것이 아닌 재설정한다.
실제로 네이버 같은 사이트 비밀번호를 찾을때 비밀번호를 직접 알려주지 않고, 새 비밀번호 설정하라고 안내가 온다.
왜냐하면 굳이 복호화할 이유가 없기 때문이다.
💡 그럼 복호화가 되어있지 않으면 비교는 어떻게 할까?
기존에 데이터베이스에 저장된 암호화된 비밀번호와,
로그인할 때 입력받은 비밀번호를 단방향 암호화를 통해 비교하면 된다.
즉 기존의 비밀번호는 어디에도 저장되지 않고 암호화된 문자열로만 비교하는 방법인 셈이다.
단방향 암호화는 Hash 알고리즘을 사용한다. 임의의 문자열을 고정된 길이의 다른 문자열로 변경하는 것이다.
비밀번호가 123, 123456으로 길이가 달라도 Hash 알고리즘에서 길이를 5로 설정했다면,
비밀번호는 abfe1, bf3sj처럼 5글자로 변경된다.
간단한 예제를 들겠다.
- createHash(algorithm[,options]): 사용할 해시 알고리즘을 입력한다.
md5,sha1, sha256, sha512등이 가능하다. - update(data[,inputEncoding]): 변환할 문자열을 입력한다.
- digest([encoding]): 인코딩할 알고리즘을 넣어준다. base64, hex, latin1이 주로 사용된다. 변환된 문자열을 반환한다.
const string = 'this is my password'
//1. 단순 해싱으로 비밀번호 해싱
let hashAlgorithm = crypto.createHash('sha512'); // sha512 암호 알고리즘 사용
//선택된 알고리즘으로 해싱
let hashing = hashAlgorithm.update(string);
//표시할 인코딩 설정.
let hashedString = hashing.digest('base64');
첫번째, string 변수 안에 사용할 비밀번호를 입력 받는다.
두번째, 사용할 해시함수를 선택한다 위의 코드에서는 "sha512" 방식을 선택 하였다.
세번째, 선택한 해시함수와 나의 비밀번호를 해싱하는 과정을 실행한다.
마지막으로 "base64"로의 인코딩 된 digest 코드를 만들어 낸다.
하지만 이러한 단순방식은 같은 알고리즘과 같은 인코딩 방식을 선택하여 암호화하면
같은 결과을 뱉어낼 뿐이다.
보통의 해쉬 함수는 속도가 중요하기 때문에, 충분히 빠르다.
보안에서는 이 빠른 게 오히려 공격자에게 좋다. 왜냐하면 가능한 모든 입력 값에 대한 출력 값을 정리해놓고, 정리해 놓은 출력 값과 실제 데이터베이스에 저장되어 있는 값을 일일히 비교하는 작업이 가능하기 때문이다.
해쉬 함수의 성능에 따라 1초에 몇억 번을 수행할 수 있다고 하니, 입력 값을 몇백 억, 몇 조 단위까지 정리해 놓을 수 있다면 비밀번호를 뚫는 게 불가능해보이지가 않는다.
이렇게 입력값-출력값을 저장해 놓는 것을 레인보우 테이블(Rainbow Table) 이라고 한다.
(레인보우 테이블은 공간 효율을 위해 실제로 입력 값과 출력 값이 1:1로 구성되어 있는 건 아니다.)
자 그렇다면 레인보우 테이블이 쓸모없게 만들어버리거나,
아예 레인보우 테이블을 만들기 불가능하도록 하면 되지 않을까?
소금을 뿌려 해커를 방해하자
첫번째로 salt 라는 기법이 있다.
복호화를 방해하기 위해 단방향 암호화시 소금(Salt)를 뿌려 해커가 복호화 하는 것을 방해하는 방법이다.
이는 입력으로 들어가는 비밀번호에 추가 문자열을 덧붙인다.
그래서 A 유저와 B 유저의 비밀번호가 같다 하더라도 같은 해쉬 출력 값을 가지고 있지 않도록 한다.
만약 A 유저의 비밀번호가 털렸다 하더라도 B는 아직 안전한 셈이다.
하지만, 모든 패스워드에 같은 소금정보를 사용하게 되면 해커가 한번 소금정보를 알아 냈을때 다른 비밀번호도 쉽게 뚫릴 것이다.
따라서, salt 값은 암호화 최초에 설정하되, 항상 비밀번호에 매번 같은 salt 가 추가되어야 출력 값도 같으므로 salt 값을 어디엔가 잘 가지고 있어야 한다.
// 랜덤바이트를 생성하여 임의 소금정보를 생성
crypto.randomBytes(32, function(err, buffer){
//32bit 길이의 random byte 생성
if(err){
console.log(err);
} else{
// ...
}
})
솔트와 패스워드의 다이제스트를 데이터베이스에 저장하고, 사용자가 로그인할 때 입력한 패스워드를 해시하여 일치 여부를 확인할 수 있다.
이 방법을 사용할 때에는 모든 패스워드가 고유의 솔트를 갖고 솔트의 길이는 32바이트 이상이어야 솔트와 다이제스트를 추측하기 어렵다.
유저 | 비밀번호 | 입력값(비밀번호 + salt) | 출력값 |
A | 1234 | 1234_a_user_salt | 9d0f1dbef956ff0d418043acc57f6330fc4521c89053921f3668a7f805c2d39e |
B | 1234 | 1234_b_hello_world | 446474b819d6debddd55d0944e3912f33fe97bbcf6d1e5005130bb428dd1d36c |
해커를 좀 더 괴롭히는 키 스트레칭(Key Stretching)
키 스트레칭은 솔트와 패스워드를 해시함수에 넣는 과정을 반복하여 해커가 복호화 하는 것을 아주 귀찮고 귀찮게 하는 방법이다.
즉, 출력 값을 아주 느리게 산출되도록 하는 방법이다.
기껏해야 1초에 5번 출력값을 계산해야 할 만큼 계산량이 많다면 레인보우 테이블을 제작하는 데도 어마어마한 시간과 비용이 들어갈 것이다.
암호화 방법에는 pbkdf, scrypt, bcrypt 이 세가지 방법이 있다.
pbkdf 방식 보다는 scrypt방식이 더 안전하다고 하다.
하지만 알고리즘이 복잡한 만큼 해시와 이터레이션에 사용되는 시간이 더 오래 걸려, 오히려 해커가 브루트 포스같은 공격을 해대면 뚫리지는 않을지라도 서버에 엄청난 부하가 가해지는 역효과가 발생 할 수도 있다.
예시로 pbkdf를 사용해보자.
crypto.pbkdf2(string, buffer.toString('base64'), 130495, 64, 'sha512', function(err, hashed) {
if(err){
console.log(err);
} else{
console.log(hashed.toString('base64'));
}
});
/* 파라미터 설명 */
crypto.pbkdf2(
string, // 해싱하려 했던 문자열(패스워드)
buffer.toString('base64'), // 인코딩
130495, // 이터레이션 반복횟수를 지정. 반복횟수가 많아 질수록 복호화하기 어려워지지만 그만큼 시간도 많이 소모된다.
// (1초에 5번 정도 비교할 수 있는 반복횟수를 넣어주는게 좋다고 한다)
64, // digest의 길이를 설정
'sha512', // 암호화 알고리즘
function(err, hashed) { } // 콜백
);
실전) 회원가입에 적용하기
[createSalt]
// 암호화 처리하는데 시간 걸리니까 비동기로 처리
const createSalt = () =>
new Promise((resolve, reject) => {
crypto.randomBytes(64, (err, buf) => {
if (err) reject(err);
resolve(buf.toString('base64'));
});
});
우선 Crypto 모듈의 randomBytes 메소드를 통해 Salt를 반환하는 함수를 작성한다.
[createHashedPassword]
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로 해싱한 암호화된 비밀번호가 생성된다.
이 함수는 password와 salt 모두를 반환하고 데이터베이스에 둘 다 넣어주면 된다.
키 스트레칭은 9999로 해놓았는데 딱 맞아 떨어지는 숫자말고 적당히 큰 수를 넣어줘도 상관없다.
const { password, salt } = await createHashedPassword(req.body.user.password);
이런식으로 암호화된 비밀번호와 salt를 생성해서 가져온 후,
await models.user
.create({
...req.body.user,
password,
salt,
})
이렇게 DB에 넣어주면 된다.
회원가입 로직은 이렇게 구현하면 끝이다.
로그인에 적용하기
[makePasswordHashed]
const makePasswordHashed = (userId, plainPassword) =>
new Promise(async (resolve, reject) => {
// userId인자로 해당 유저 salt를 가져오는 부분
const salt = await models.user
.findOne({
attributes: ['salt'],
raw: true,
where: {
userId,
},
})
.then((result) => result.salt);
// 위에서 가져온 salt와 plainPassword를 다시 해시 암호화 시킴. (비교하기 위해)
crypto.pbkdf2(plainPassword, salt, 9999, 64, 'sha512', (err, key) => {
if (err) reject(err);
resolve(key.toString('base64'));
});
});
비밀번호와 유저 ID를 인자로 받아 패스워드를 암호화한다.
단방향 암호화 방식이기 때문에 유저가 보낸 Plain Password를 위에서 한 방식대로 그대로 암호화해서 비교하면 된다.
여기서 다른 것은 회원가입에서는 salt를 랜덤 문자열로 만들고, 로그인에서는 회원가입에서 만들어진 salt를 가져와서 해싱하는 것이다.
암호화 방식이 똑같기 때문에 키 스트레칭이나 해싱 알고리즘이 서로 다르면 안된다.
함수를 사용하는 예를 들면,
const { userId, password: plainPassword } = req.body.user;
const password = await makePasswordHashed(userId, plainPassword);
그러면 password에는 비교할 암호화된 문자열이 만들어질 것이고, 각자 사용하는 데이터베이스에서 유저의 ID와 password를 비교해주면 된다.
양방향 암호화 방법
양방향 암호화에는 대칭형 암호화와 비대칭형 암호화가 있다.
양방향 암호화는 암호화된 문자열을 기존 문자열로 복호화 할 수 있는 암호화 기법이다.
암호화 된 문자열을 복호화 하기 위해선 암호화 할 때 사용했던 키와 같은 것을 사용해야 한다.
대칭형 암호화
암호화에 사용되는 메소드
- createCipher(algorithm, key[,options]): 암호화 알고리즘과 key를 넣어 줍니다. 예시에서는 암호화 알고리즘은 'des'를 입력하였습니다.
- encrypt.update(data[,inputEncoding][,outputEncoding]): 암호화 할 문자열과 문자열의 인코딩, 출력 문자열의 인코딩을 입력합니다.
- encrypt.final([outputEncoding]): 출력된 문자열의 인코딩을 입력합니다.
복호화에 사용되는 메소드
- createDecipher(algorithm, key[,options]): 복호화 할때 사용하는 메소드입니다. 암호화에 사용했던 알고리즘과 key를 입력한다.
- decode.update(data[,inputEncoding][,outputEncoding]): 암호화된 문자열, 그 문자열의 인코딩, 복호화 할 인코딩을 순서대로 입력한다.
- decode.final[outputEncoding]): 복호화 결과의 인코딩을 입력한다.
const password = 'qpmz0192'
const password2 = '2910zmpq'
const key = 'gracefulife' // 대칭형 키
// 암호화 메서드
const cipher = (password, key) => {
const encrypt = crypto.createCipher('des', key) // des알고리즘과 키를 설정
const encryptResult = encrypt.update(password, 'utf8', 'base64') // 암호화
+ encrypt.final('base64') // 인코딩
console.log(encryptResult)
return encryptResult
}
// 복호화 메서드
const decipher = (password, key) => {
const decode = crypto.createDecipher('des', key)
const decodeResult = decode.update(password, 'base64', 'utf8') // 암호화된 문자열, 암호화 했던 인코딩 종류, 복호화 할 인코딩 종류 설정
+ decode.final('utf8') // 복호화 결과의 인코딩
console.log(decodeResult)
}
const encrypt = cipher(password, key) // dzzmUb9NevZXKjSIZiZbHQ
decipher(encrypt, key) // qpmz0192
const encrypt2 = cipher(password2, key) // vPwzzznk4gezbixB1Fr9wA
decipher(encrypt2, key) // 2910zmpq
createCipheriv()
기존 createCipher는 보안에 취약해서 새로 나온 메서드다.
다만, key나 문자열 길이 제약이 많다.
맺음
사실 암호화 코딩은 따로 암호학을 배우지 않는한 매우 어렵다.
따라서 남이 만들어놓은 암호화 모듈을 사용하는것이 좋다. 대표적으로 crypto-js가 있다.
혹은 bcrypt 모듈을 사용하는 것도 하나의 방법이다.
bcrypt는 crypto 보다 보안이 더 뛰어나며 사용 문법도 매우 쉽다. 한번 익혀보는걸 강력히 추천한다.
Reference
https://zinirun.github.io/2020/12/02/node-crypto-password/
https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=tpgns8488&logNo=221336473460
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.