BCrypt 동작원리 파헤치기(BCryptPasswordEncoder)
BCrypt 암호화를 사용하면서 내부적으로 랜덤 한 salt가 생기고, 그에 따른 결과 해시 값이 매번 바뀌는 것을 확인하며 동작 원리가 궁금해서 찾아본 내용입니다. BCrypt 암호화는 무엇인지? 동작원리는 무엇인지? Java 코드를 통해 살펴보겠습니다.
BCrypt란?
BCrypt는 블로피시(Blowfish) 암호에 기반을 둔 암호화 해시 함수로 현재까지 사용 중인 가장 강력한 해시 메커니즘 중 하나이며, 1999년 USENIX에서 발표되었습니다.
BCrypt는 패스워드를 해싱할 때 내부적으로 랜덤 한 salt를 생성하기 때문에 같은 문자열에 대해서 매번 다른 해싱 결과를 반환하는데요.
(하지만 해싱 결과로 반환되는 String의 길이는 매번 60으로 동일합니다.)
이처럼 salt가 통합된 형식으로 인해 레인보 테이블(rainbow table) 공격을 방지할 수 있으며, 반복 횟수를 늘려서 연산 속도를 늦출 수 있기 때문에 연산 능력이 증가하더라도 브루트 포스(brute force) 검색 공격에 대비할 수 있습니다.
또한 해시 값 내부에 salt 값이 포함되기 때문에 salt 값을 따로 저장하지 않아도 해싱된 값과 평문을 비교할 수 있다는 특징이 있습니다.
BCrypt는 C, C++, C#, Go, Java, Javascript, Python, Ruby 등의 언어로 구현된 구현체가 존재합니다.
/*
레인보 테이블(rainbow table)은 해시 함수를 사용하여 변환 가능한 모든 해시 값을 저장시켜 놓은 표입니다.
해시 함수를 이용하여 저장된 비밀번호로부터 원래의 비밀번호를 추출해 내는데 많이 사용되며, 조합 가능한 모든 문자열을 하나씩 대입해보는 방식으로 문제를 푸는 브루트 포스 공격을 뒷받침해주는 역할을 하기도 합니다.
*/
BCryptPasswordEncoder란?
BCryptPasswordEncoder는 PasswordEncoder 인터페이스를 구현한 클래스인데요.
해당 클래스는 BCrypt 해싱 함수(BCrypt hashing function)를 사용해서 비밀번호를 해싱해주는 encode() 메서드와 확인 요청된 비밀번호와 저장된 비밀번호의 일치 여부를 확인해주는 matches() 메서드를 제공합니다.
(스프링 시큐리티 5.4.2부터는 upgradeEncoding() 메서드가 추가되었습니다.)
해당 클래스를 인스턴스화 할 때 사용되는 인자 strength 값을 통해 해시의 강도를 조절할 수 있습니다.
/*
The larger the strength parameter the more work will have to be done (exponentially) to hash the passwords. The default value is 10. (between 4 and 31)
*/
동작원리 파헤치기
먼저 비밀번호를 해싱해주는 encode() 메서드를 살펴보면 내부적으로 getSalt() 메서드를 통해 매번 새로운 salt 값을 생성하는 것을 볼 수 있는데요.
사용자에게 입력받은 password 값과 이렇게 내부적으로 생성되는 salt 값을 가지고 BCrypt.hashpw() 메서드에서 최종적으로 해싱된 비밀번호 값을 얻게 됩니다.
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를 얻는 과정을 살펴보면, 해당 코드는 getSalt() 메서드 내부에서 동작하는 gensalt() 메서드인데요.
여기서 prefix 값은 BCryptVersion이라는 enum 값이 사용되며, BCryptPasswordEncoder의 strength 값이 log_rounds로 적용됩니다.
//BCryptVersion
$2A("$2a"), $2Y("$2y"), $2B("$2b")
//salt 예시
$2a$15$R5OVo/sLPfDMlTG6kyZwiu
//real_salt 예시
R5OVo/sLPfDMlTG6kyZwiu
//최종 해싱 값
$2a$15$R5OVo/sLPfDMlTG6kyZwiuXlNeRsdspdzEjhhCVC4gsycIQTGYsvm
이때 생성되는 salt의 예시는 위와 같은데요.
salt = BCryptVersion + "$" + log_rounds + "$" + real_salt 형태가 되며, 최종적인 해싱 값은 salt 값 + (원문 + salt 값을 해싱한 값)이 되는 것입니다.
이어서 확인 요청된 비밀번호와 저장된 비밀번호의 일치 여부를 확인하는 matches() 메서드입니다.
해당 메서드의 동작 순서를 따라가 보면 아래 checkpw() -> equalsNoEarlyReturn()으로 이어지는데요.
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));
}
equalsNoEarlyReturn() 메서드를 통해 값을 비교하는데, 이때 암호화할 때 사용되었던 hashpw() 메서드가 다시 사용되는 것을 볼 수 있는데요.
이때 동작되는 원리는 plaintext와 저장된 hashed 값을 넣어, hashed 값에서 real_salt를 추출하여 plaintext와 real_salt 값으로 다시 해싱을 하고, 이렇게 해싱되어서 나온 결과와 저장된 해싱 값을 비교하여 일치 여부를 확인하게 되는 것입니다.
=> 앞부분의 BCrypt 이론적 정의에 나온 것처럼 해시 값 내부에 salt 값이 포함되어 있기 때문에 salt 값을 따로 저장하지 않아도 해싱된 값과 평문을 비교할 수 있게 되는 것입니다.
< 참고 자료 >