etc

BCrypt로 Password Hashing 하기

사용자의 비밀번호와 같은 credential한 값은 hashing을 통해 관리자 조차도 원래 값을 알 수 없도록 암호화하여 저장해야한다.
이번 포스팅은 비밀번호 암호화 관련하여 BCrypt를 적용한 것과 관련하여 알아본 바를 정리한다.

SHA는 사용하지 마라

일반적으로 Hashing에 사용되는 SHA 방식의 경우 비밀번호와 같은 보안과 관련되서는 사용되지 않는다.
왜냐하면 SHA 방식은 빠르기 때문에 해커들이 반복 연산을 보다 빠르게 할 수 있기 때문이다.
이는 해커가 비밀번호를 얻어내기 좀 더 수월하다는 위험성을 갖는다.

왜 BCrypt를 사용하는가

BCrypt 같은 경우 보안상의 목적으로 의도적으로 느리게 설계되었다.
그렇기 때문에 Hash 비밀번호를 탈취당한다 하여도 쉽게 원래 비밀번호를 얻기 힘들다.

Salt를 어디에 저장해야할까?

그러면 비밀번호 Hashing에 있어 필요한 Salt값은 어디에 저장해야할까?
결론부터 말하면 저장할 필요가 없다.

BCrypt의 경우 Hash 값 내부에 Salt값이 포함되기 때문에
Salt값을 따로 저장하지 않아도 Hashing 된 값과 평문을 비교할 수 있다.

이는 Spring Security에서 사용되는 BCryptPasswordEncoder 내부 코드로 보아도 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package org.springframework.security.crypto.bcrypt;

public class BCryptPasswordEncoder implements PasswordEncoder {
// ...

public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.length() == 0) {
logger.warn("Empty encoded password");
return false;
}

if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
logger.warn("Encoded password does not look like BCrypt");
return false;
}

return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
package org.springframework.security.crypto.bcrypt;

public class BCrypt {
// ...

public static boolean checkpw(String plaintext, String hashed) {
return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed));
}

static boolean equalsNoEarlyReturn(String a, String b) {
return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
}

위 코드 중 BCrypt.checkpw에서 Salt값 없이 평문과 Hash값만을 사용하여
(hashedhashpw(plaintext, hashed) 가 동일한 값인지 확인을 하여) 값을 검증한다.

테스트 코드로 직접 확인해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCrypt;

import static org.junit.jupiter.api.Assertions.*;
import static org.assertj.core.api.Assertions.*;

class BCryptTest {
@DisplayName("Hashing된 값은 Salt값으로 시작한다")
@Test
void hashedPasswordStartsWithSalt() {
final String rawPassword = "rawPw1234";
final String salt = BCrypt.gensalt();

final String hashedPassword = BCrypt.hashpw(rawPassword, salt);
final String shouldEqualToHashedPassword = BCrypt.hashpw(rawPassword, hashedPassword);

assertThat(hashedPassword.startsWith(salt)).isTrue();
}

@DisplayName("Salt 값 없이 비밀번호 검증을 할 수 있다: 비밀번호가 같을 때")
@Test
void checkPasswordWithoutSalt_Correct() {
final String rawPassword = "rawPw1234";
final String salt = BCrypt.gensalt();

final String hashedPassword = BCrypt.hashpw(rawPassword, salt);

assertThat(BCrypt.checkpw(rawPassword, hashedPassword)).isTrue();
}

@DisplayName("Salt 값 없이 비밀번호 검증을 할 수 있다: 비밀번호가 다를 때")
@Test
void checkPasswordWithoutSalt_Wrong() {
final String rawPassword = "rawPw1234";
final String salt = BCrypt.gensalt();

final String hashedPassword = BCrypt.hashpw(rawPassword, salt);

final String wrongPassword = rawPassword + "12345";

assertThat(BCrypt.checkpw(wrongPassword, hashedPassword)).isFalse();
}
}

위에서 볼 수 있듯 Salt값 없이 Hashing 전의 평문과 Hashing 된 값을 통해 비밀번호 검증을 할 수 있다.
그렇기 때문에 BCrypt로 hashing한 비밀번호는 따로 Salt값 저장에 대한 고민없이 hash 값만 잘 저장하면 된다.


References

Share