패스워드는 보통 평문이 아니라, 암호화 알고리즘을 통해 생성된 난수로 DB에 저장된다.
하지만 알고리즘을 통해 패스워드를 암호화 하더라도, 같은 문자열이라면 암호화 된 문자열 또한 같은 값이 나올 것이고,
이는 Rainbow Table을 이용한 공격에 취약하다는 것을 의미한다.
만약 아래와 같이 동일한 패스워드에 같은 "SHA-256" 알고리즘을 적용한다면...
@Test
void encodeTest() throws NoSuchAlgorithmException {
String rawPassword = "password";
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] firstHashedPassword = md.digest(rawPassword.getBytes(StandardCharsets.UTF_8));
String firstPassword = HexUtils.toHexString(firstHashedPassword);
System.out.println("first = " + firstPassword);
byte[] secondHashedPassword = md.digest(rawPassword.getBytes(StandardCharsets.UTF_8));
String secondPassword = HexUtils.toHexString(secondHashedPassword);
System.out.println("second = " + secondPassword);
}
결과는 아래와같이 동일한 값을 가지게 된다.
이러한 위험에 대한 대책으로 보통 Salt를 활용한다.
패스워드 입력값에 랜덤한 값(Salt)를 더해서 패스워드를 생성한다. 이는 결국 동일한 값을 입력하더라도 Salt로 인해 다른 해시값을 가지도록 하기 때문에, Rainbow Table을 활용한 공격을 방어할 수 있게 된다.
Spring Security의 BcryptPasswordEncoder는 패스워드 인코딩 시 자동으로 salt를 대입하여 준다.
@Test
void bcryptEncodeTest() {
String rawPassword = "password";
String firstEncoded = encoder.encode(rawPassword);
System.out.println("first = " + firstEncoded);
String secondEncoded = encoder.encode(rawPassword);
System.out.println("second = " + secondEncoded);
}
동일한 패스워드를 입력하였음에도, 다른 결과값이 도출되는 것을 확인할 수 있다.
여기에서 의문이 드는 점은, 임의의 salt값을 인코딩 할 때 포함시켜 패스워드를 생성하였다 하더라도,
이 salt 값을 DB에 가지고 있지 않음에도 불구하고, 어떻게 로그인 시, 알맞은 값을 가져올 수 있을까?
bcrypt 는 로그인 시 match() 메서드를 사용하게 되는데, 이 때 bcrypt 는 패스워드에서 salt를 추출한다.
추출된 salt를 사용하여, 패스워드를 암호화하고,
암호화 한 해시와 저장된 패스워드 해시값이 동일한지 확인한다.
따라서 이전의 메서드에서 match 결과를 출력해보면..
@Test
void bcryptEncodeTest() {
String rawPassword = "password";
String firstEncoded = encoder.encode(rawPassword);
String secondEncoded = encoder.encode(rawPassword);
System.out.println("firstMatch : " + encoder.matches(rawPassword, firstEncoded));
System.out.println("secondMatch : " + encoder.matches(rawPassword, secondEncoded));
}
모두 true가 출력됨을 확인할 수 있다.
사실 bcrypy는 salt를 따로 저장하고 있지도 않고, 특별히 대단한 비밀을 가지고 있는 것도 아니다.
bcrypt 에서 salt를 생성하는 메서드를 확인해보자.
public static String gensalt(String prefix, int log_rounds, SecureRandom random) throws IllegalArgumentException {
StringBuilder rs = new StringBuilder();
byte rnd[] = new byte[BCRYPT_SALT_LEN];
if (!prefix.startsWith("$2")
|| (prefix.charAt(2) != 'a' && prefix.charAt(2) != 'y' && prefix.charAt(2) != 'b')) {
throw new IllegalArgumentException("Invalid prefix");
}
if (log_rounds < 4 || log_rounds > 31) {
throw new IllegalArgumentException("Invalid log_rounds");
}
random.nextBytes(rnd);
rs.append("$2");
rs.append(prefix.charAt(2));
rs.append("$");
if (log_rounds < 10) {
rs.append("0");
}
rs.append(log_rounds);
rs.append("$");
encode_base64(rnd, rnd.length, rs);
return rs.toString();
}
로직을 기준으로 추론해보면, 결국 salt를 생성하는 핵심 로직은 해시 된 패스워드 앞에 붙어 있는 문자열($2a$10$)이다.
그렇다면, 이렇게 salt를 노출시켜도 문제가 없을까?
- '2a'는 사용된 bcrypt알고리즘의 버전을 의미한다.
- '10'는 cost factor를 의미한다. cost factor가 10이라는 의미는 key derivation 함수가 2^10번 반복 실행된다는 의미다. 이 값이 커질 수록 해시값을 구하는 속도가 느려다.
- 뒤에 이어서 나오는 값이 바로 salt값과 암호화된 비밀번호이다. 첫 22개의 문자가 16바이트의 salt값으로 디코딩된다.
- 어차피 패스워드가 노출된다면, 암호화 알고리즘 자체가 의미가 없다.
- 기본적으로 salt는 Rainbow Table 공격으로 부터 보호하기 위한 것이기 때문에, salt가 적용되었다는 것 자체가 이미 목적을 달성한 것이다.
- salt가 노출되어도 새롭게 Rainbow Table을 생성하기는 어렵다. Rainbow Table은 생성 자체가 엄청난 시간과 리소스가 들어가는 작업이고, bcrypt 해시함수 자체가 계산시간이 오래 걸리도록 설계되어 있다고 한다.
Bcrypt 자체는 스프링 뿐만 아니라 최근에는 패스워드를 적용할 때 다양한 영역에서 사용중인 암호화 방식이다.
Spring Security의 PasswordEncoder에서 기본(default)으로 지정된 인코더라는 것은,
그만큼 검증된 방식이라고 볼 수 있을 것이다.
참고
https://jhkimmm.tistory.com/24
https://stackoverflow.com/questions/6832445/how-can-bcrypt-have-built-in-salts
'개발 > Spring' 카테고리의 다른 글
[Spring] Assertj 테스트 지원 메서드 (1) | 2023.11.26 |
---|---|
[Spring] 비동기 처리에 대한 이해(1) - 쓰레드 (1) | 2023.10.03 |
[Spring] Elasticsearch를 Spring에 적용해보자(2) (0) | 2023.08.20 |
[Spring] Kotlin + Spring 프로젝트 만들고 테스트 해보기 (0) | 2023.08.05 |
[Spring] @RequestParam / @ModelAttribute / @RequestBody / @Requestpart (0) | 2023.07.22 |