Password Encryption
회원가입 인증 시스템을 구현할 때 우리는 비밀번호를 입력한 뒤 해쉬화된 비밀번호를 DB에 저장해야할 필요가 있다.이때 어떤 알고리즘을 선택할지에 대한 고민이 생겼다.
단방향 해쉬함수의 문제점
대부분의 웹 사이트에서는 SHA-256과 같은 해시 함수를 사용해 패스워드를 암호화해 저장하고 값을 비교하는 것만으로 충분한 암호화 메커니즘을 적용했다고 생각하지만, 실제로는 다음과 같은 두 가지 문제점이 있다.
인식 가능성(recognizability)
동일한 메시지가 언제나 동일한 결과(다이제스트) 를 갖는다면, 공격자가 전처리(pre-computing)된 다이제스트를 가능한 한 많이 확보한 다음 이를 탈취한 다이제스트와 비교해 원본 메시지를 찾아내거나 동일한 효과의 메시지를 찾을 수 있다. 이와 같은 다이제스트 목록을 레인보우 테이블(rainbow table)이라 하고, 이와 같은 공격 방식을 레인보우 공격(rainbow attack)이라 한다. 게다가 다른 사용자의 패스워드가 같으면 다이제스트도 같으므로 한꺼번에 모두 정보가 탈취될 수 있다.실제 해킹실습에서 실험해본 경험이 있는데 특수문자가 안섞인 비밀번호는 3분안에 탈취된 기억이 있다.
속도(speed)
해시 함수는 암호학에서 널리 사용되지만 원래 패스워드를 저장하기 위해서 설계된 것이 아니라 짧은 시간에 데이터를 검색하기 위해 설계된 것이다. 바로 여기에서 문제가 발생한다. 해시 함수의 빠른 처리 속도로 인해 공격자는 매우 빠른 속도로 임의의 문자열의 다이제스트와 해킹할 대상의 다이제스트를 비교할 수 있다. 해시 함수의 빠른 처리 속도는 사용자들보다 공격자들에게 더 큰 편의성을 제공하게 된다
MD5
처음 구현했던 사용했던 알고리즘은 MD5이다.
단방향 함수의 대표적인 예시이다.
임의의 길이의 값을 입력받아서 128비트 길이의 해시값을 출력하는 알고리즘이다.
MD5는 단방향 암호화이기 때문에 출력값에서 입력값을 복원하는 것은 일반적으로 불가능하다. 같은 입력값이면 항상 같은 출력값이 나오고, 서로 다른 입력값에서 같은 출력값이 나올 확률은 극히 낮다(0은 아니며 발생할 수 있다.).
MD5는 속도가 너무 빠르기때문에 salt같은 수단을 붙이더라도 무차별 대입이나 사전 공격에 너무 취약하다
salt를 붙여도 자르면 MD5는 MD5기 때문에 소용이 없다. 또한 salt를 따로 저장해야하기 떄문에 DB의 공간을 낭비하게 된다. 현재는 대형 파일의 무결성 검사 정도에 쓰인다.
SHA를 암호해싱에 사용하는 암호화 함수들은 GPU를 이용한 공격에 취약하며(SHA family는 연산속도가 매우빠르기 떄문) 많은 메모리를 필요로 하지 않는 점을 지적하고 있다.
SHA가 보안에 결함이 있어서 안전하지 않기 때문이 아니라, SHA는 일반적으로 GPU연산에 유리한 32비트 논리 및 산술 연산만 사용하기 때문에, 공격자가 빠른연산으로 공격할 수 있기 때문입니다.Bcrypt 설계자들은 이런 문제로 SHA가 아닌 Blowfish를 이용하여 구현하였다고 한다.
BCrypt
BCrypt는 블로피시(Blowfish) 암호에 기반을 둔 암호화 해시 함수로 현재까지 사용 중인 가장 강력한 해시 메커니즘 중 하나이며 BCrypt는 패스워드를 해싱할 때 내부적으로 랜덤 한 salt를 생성하기 때문에 같은 문자열에 대해서 매번 다른 해싱 결과를 반환한다.
3. PBKDF2(Password-Based Key Derivation Function)
pbkdf2_hmac(해시함수(sha256..), password, salt, iteration, DLen)
- 해시함수의 컨테이너 역할을 한다.
- 검증된 해시함수만을 사용한다.
- 해시함수와 salt를 적용 후 해시 함수의 반복횟수를 지정하여 암호화할 수 있다.
- 가장 많이 사용되는 함수. ISO 표준에 적합하며 NIST에서 승인된 알고리즘이다.
나는 Bcrypt 를 선택하겠다.
중점을 두고 싶은 기준은
1. 속도
2. 보안성
3. 대중성
(1) 속도
Bcrypt의 해싱 시간을 편리하게 조율할 수 있다. 실제 실험 결과를 본 결과 10번의 알고리즘 복잡도를 주면 65.683ms로 빠른 속도임을 볼 수 있다. 물론 이후에는 기하급수적으로 늘어나지만 본 프로젝트에서 그정도의 보안성까지 고려할 필요가 없다고 생각한다. 복잡도가 증가할수록 hash하는데 걸리는 시간이 기하급수적으로 늘어나기 때문에 일반적으로 10 ~ 12가 추천된다고 한다.
These are the results:
bcrypt | cost: 10, time to hash: 65.683ms
bcrypt | cost: 11, time to hash: 129.227ms
bcrypt | cost: 12, time to hash: 254.624ms
bcrypt | cost: 13, time to hash: 511.969ms
bcrypt | cost: 14, time to hash: 1015.073ms
bcrypt | cost: 15, time to hash: 2043.034ms
bcrypt | cost: 16, time to hash: 4088.721ms
bcrypt | cost: 17, time to hash: 8162.788ms
bcrypt | cost: 18, time to hash: 16315.459ms
bcrypt | cost: 19, time to hash: 32682.622ms
bcrypt | cost: 20, time to hash: 66779.182ms
Plotting this data in Wolfram Alpha to create a least-squares fit graph, we observe that the time to hash a password grows exponentially as the cost is increased in this particular hardware configuration:

(2) 보안성
우선 PBKDF2의 한 가지 약점은 임의로 많은 컴퓨팅 시간이 걸리도록 반복 횟수를 조정할 수 있지만 작은 회로와 매우 적은 RAM으로 구현할 수 있어 애플리케이션별 집적 회로 또는 그래픽 처리 장치를 사용한 무차별 대입 공격을 상대적으로 저렴하게 만들 수 있다는 것입니다. [12] bcrypt 패스워드 해싱 함수는 더 많은 양의 RAM을 필요로 하며(그러나 여전히 별도로 조정할 수 없습니다. 즉, 주어진 CPU 시간 동안 고정됨), 이러한 공격에 대해 약간 더 강력합니다.
앞서 말한 GPU를 늘릴시 빠른 연산으로 처리할수 있다는것이다.
대부분의 패스워드는 최소 8자 이상이도록 해야 하며 최대 64자 이상 입력 가능으로 제한을 두고 있다.
크래킹에 필요한 비용을 보면 다음과 같이 PBKDF2보다 BCrypt가 비용이 늘 많이 듦을 볼 수 있다.
(3) 개인적으로 많이 쓰는데는 여러 이유가 있다고 생각한다.
내가 프로젝트에 적합하다고 생각해서 고른 이유는 어쩌면 소수지만 , 그 외에도 많은 이유가 있을 것이다.
그 많은 이유를 대변하는게 바로 사용량이라고 생각한다.
구글 트렌드를 보면 전세계, 대한민국 둘다 BCrypt가 앞서는 것을 확인할 수 있다.
물론 BCrypt보다 좋은 Argon2와 Scrypt도 선택지로 있을 것이다.
하지만 여러 조사 결과 이번 프로젝트에 맞는 스택으로 Bcrypt를 사용하도록 하겠다.
다음은 스프링 시큐리티에서 제공하는 BCrypt 클래스이다.
public class BCryptPasswordEncoder implements PasswordEncoder {
}
encode()함수를 호출해서 암호화된 알고리즘을 얻을 것이다.
@Override
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
String salt = getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);
}
파라미터의 CharSequence가 무언지 궁금해서 보았다.
CharSequence? String 객체에 보관하는 문자열은 유니코드로 변형되므로 HTML과 같은 마크업 문자를 입출력할 때 문제가 발생한다. 이와 같이 마크업 문자를 입력하여 사용할 수 없기 때문에 변경할 수 없는 문자열이라고 부른다.
이를 보완한게 Char Sequence는 클래스가 아니라 인터페이스이다.
인터페이스느 다양한 종류의 char에 대해 균일한 읽기 전용 접근 권한을 제공한다,
대표적인 클래스는 String SpannableStringBuilder,String Builder,String Buffer등이 있다.
CharSequence 객체를 보관하는 문자열은 같은 유니코드라도 마크업 문자를 사용할 수 있다.
우선 gesalt()함수에서 랜덤한 salt를 얻는다.
salt = BCryptVersion + "$" + log_rounds + "$" + real_salt 형태가 되며, 최종적인 해싱 값은 salt 값 + (원문 + salt 값을 해싱한 값)이 된다.
예시로 해쉬된 값이 다음과 같다면
$ 2b$ + 10 + $PzmixJm3ND5E6x.+ dakYDpuDNhIDzvqO84UoaTv/k8s8KZquT3uZ9i
👉 $ 2b$
- Algorithm 정보
👉 10
- Algorithm 복잡도(Cost-비용)
👉 PzmixJm3ND5E6x
- Salt
👉 dakYDpuDNhIDzvqO84UoaTv/k8s8KZquT3uZ9i
- Hash: 암호화 된 정보 (base64-encoded)
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();
}
입력한 password와 salt룰 가지고 hashpw를 호출하여 암호화된 String을 얻는다.
public static String hashpw(String password, String salt) {
byte passwordb[];
passwordb = password.getBytes(StandardCharsets.UTF_8);
return hashpw(passwordb, salt);
}
public static String hashpw(byte passwordb[], String salt) {
return hashpw(passwordb, salt, false);
}
private static String hashpw(byte passwordb[], String salt, boolean for_check) {
BCrypt B;
String real_salt;
byte saltb[], hashed[];
char minor = (char) 0;
int rounds, off;
StringBuilder rs = new StringBuilder();
if (salt == null) {
throw new IllegalArgumentException("salt cannot be null");
}
int saltLength = salt.length();
if (saltLength < 28) {
throw new IllegalArgumentException("Invalid salt");
}
if (salt.charAt(0) != '$' || salt.charAt(1) != '2') {
throw new IllegalArgumentException("Invalid salt version");
}
if (salt.charAt(2) == '$') {
off = 3;
}
else {
minor = salt.charAt(2);
if ((minor != 'a' && minor != 'x' && minor != 'y' && minor != 'b') || salt.charAt(3) != '$') {
throw new IllegalArgumentException("Invalid salt revision");
}
off = 4;
}
// Extract number of rounds
if (salt.charAt(off + 2) > '$') {
throw new IllegalArgumentException("Missing salt rounds");
}
if (off == 4 && saltLength < 29) {
throw new IllegalArgumentException("Invalid salt");
}
rounds = Integer.parseInt(salt.substring(off, off + 2));
real_salt = salt.substring(off + 3, off + 25);
saltb = decode_base64(real_salt, BCRYPT_SALT_LEN);
if (minor >= 'a') {
passwordb = Arrays.copyOf(passwordb, passwordb.length + 1);
}
B = new BCrypt();
hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 0x10000 : 0, for_check);
rs.append("$2");
if (minor >= 'a') {
rs.append(minor);
}
rs.append("$");
if (rounds < 10) {
rs.append("0");
}
rs.append(rounds);
rs.append("$");
encode_base64(saltb, saltb.length, rs);
encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
return rs.toString();
}
/**
* Generate a salt for use with the BCrypt.hashpw() method
* @param prefix the prefix value (default $2a)
* @param log_rounds the log2 of the number of rounds of hashing to apply - the work
* factor therefore increases as 2**log_rounds.
* @param random an instance of SecureRandom to use
* @return an encoded salt value
* @exception IllegalArgumentException if prefix or log_rounds is invalid
*/
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();
}
pbkdf 2 - AES 암호화 여러 파일 - 암호화 스택 교환 (stackexchange.com
'2023스마일게이트윈터데브캠프' 카테고리의 다른 글
2023 스마일게이트 윈터 데브 캠프 - 개인프로젝트(합격부터) (2) | 2023.03.31 |
---|---|
[스마일게이트 캠프] 실시간 채팅 구현하기 - Web Socket (0) | 2023.02.17 |
Spring Security - (1) Authentication (0) | 2022.12.06 |