SHA256은 미국 국가안보국(NSA)에서 설계한 해시 함수이다.
NIST(National Institute of Standards and Technology)에서 관리하는 FIPS (Federal Information Processing Standards) 180-4 문서에 정의되어 있다.
https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf
굳이 해당 내용을 다 알고있을 필요는 없다.
복잡한 해시 알고리즘을 통해 해싱 한다는것만 가져가면 된다.
SHA-256은 SHA-2 계열에 속한다.
SHA-3 계열은 SHA-2에 비해 더 강력한 보안을 제공하기 위해 개발되었지만, 오랜 기간 동안 널리 사용되면서 안정성과 보안이 검증되었기 때문에 대부분의 시스템에서 SHA-2 계열로 사용되고있다.
A -> B로 통신할때, 256비트로 만들어진 64자리 문자열을 생성하여 해싱하는 단방향(복호화 X) 알고리즘이다.
ex)
java의 MessageDigest를 사용해서 구현해보자.
자바 표준 라이브러리(java.security)에서 보안 관련 기능을 제공하는 패키지이며 jdk(Java Development Kit)에 포함되어있다.
당연히 java8에도 포함되어있다.
https://docs.oracle.com/javase/8/docs/api/java/security/MessageDigest.html
MessageDigest (Java Platform SE 8 )
This MessageDigest class provides applications the functionality of a message digest algorithm, such as SHA-1 or SHA-256. Message digests are secure one-way hash functions that take arbitrary-sized data and output a fixed-length hash value. A MessageDigest
docs.oracle.com
코드의 자세한 내용은 주석으로 표시해두었다.
클린코드도 당연히 중요하다.
그러나
클린코드를 한답시고 코드를 줄이면 오류를 보게되고 다시 구글링하게 된다.
코드에 정답은 없다고 생각한다.
해당 코드는 자주 사용될 수 있기 때문에 주석을 활용해 효율을 중요시하겠다.
public static String encryptSHA256(String str) {
try {
// SHA-256 알고리즘을 구현하는 MessageDigest 객체를 생성한다.
MessageDigest md = MessageDigest.getInstance("SHA-256");
// str 문자열을 UTF-8 인코딩하여 byte 배열로 변환한다. default = UTF-8
byte[] bytes = str.getBytes();
// 해시할 데이터를 MessageDigest 객체에 전달한다. update를 통해 여러번 호출하여 데이터를 단계적으로 입력할 수 있다.
md.update(bytes);
// digest 메서드를 호출하여 최종 해시 값을 계산한다.
byte[] hash = md.digest();
// 해시 값을 16진수 문자열로 변환하기 위해 StringBuilder 객체를 생성한다. StringBuilder는 String 보다 성능이 좋다.
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
// byte b를 2자리의 16진수 형식으로 변환한다. ex) b = 10 -> 0a
String format = String.format("%02x", b);
sb.append(format);
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
테스트도 문제가 없다.
그런데 여기서 한가지 드는 생각이 있다.
SHA-256 해시는 동일한 문자열에 동일한 16진수 해시값을 갖게된다.
해커들은 해시 함수의 모든 입력값에 대한 결과값을 표로 정리한 레인보우 테이블을 만들었다.
기존 비밀번호가 123456 이라면 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92 해시로 123456을 유추할수 있다.
이를 방지하기 위해 솔트(salt)를 적용해보자
기존 123456에 솔트 abc(원래는 랜덤 데이터, 예시로 abc를 사용했다.)를 더한다.
123456abc = 931145d4ddd1811be545e4ac88a81f1fdbfaf0779c437efba16b884595274d11 해시로 완전히 바뀌었다.
나아가 SHA-256 해시함수로 DB 사용자 정보를 저장해야한다면 어떻게 해야될까?
1. 비밀번호 저장(123456)
- 기본적으로 password는 클라이언트만 알수있다. 당연히 안된다.
2. 솔트값 저장(abc)
- 솔트값만으로 로그인, 비밀번호 변경등이 불가능 하다.
3. 해시될 문자열 저장(123456abc)
- 비밀번호, 솔트값을 유추할수 있다. 당연히 안된다.
4. 해시된 값 = SHA(비밀번호 + 솔트값) 저장
- 로그인 과정에서 문제가 생긴다.
로그인 과정
1. 로그인 할때 사용자가 비밀번호를 입력한다
2. 입력한 비밀번호 + 솔트로 로그인한다.
3. 서버는 해당 사용자의 계정 정보를 가져온다. 이때 솔트값이 저장된 곳이 없기 때문에 솔트값을 알 수 없다.
즉, 클라이언트가 입력한 비밀번호에 솔트를 추가하여 해시된 값 = SHA(비밀번호 + 솔트값)을 생성할 수 없다.
5. 해시된 값 = SHA(비밀번호 + 솔트값), 솔트값 저장
로그인시 사용자가 입력한 비밀번호 + 서버에 저장된 사용자의 솔트값 으로 비밀번호를 검증할 수 있다.
코드로 적용해보자
public static String createSalt() {
// 난수 생성 알고리즘 기본값 'SHA1PRNG' (== SecureRandom.getInstance("SHA1PRNG"))
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[16];
// nextBytes 메서드를 호출하여 bytes의 크기만큼 난수로 채움.
random.nextBytes(bytes);
// 16진수 문자열로 변환
StringBuilder sb = new StringBuilder();
for(byte b : bytes) {
// byte b를 2자리의 16진수 형식으로 변환한다. ex) b = 10 -> 0a
String format = String.format("%02x", b);
sb.append(format);
}
return sb.toString();
// // Base 64 인코딩 필요시 사용
// return new String(Base64.getEncoder().encode(bytes));
}
salt 생성 함수이다.
salt 적용시 base64 인코딩도 많이 사용되어 주석으로 추가해두었다.
- Hexadecimal(16진수) 인코딩:
- e4d909c290d0fb1ca068ffaddf22cbd0, 문자열 길이 32
- Base64 인코딩:
- 5NkJwptPshygav+o33LL0A==, 문자열 길이 24
Salt 추가
public static String encryptSHA256BySalt(String str, String salt) {
try {
// SHA-256 알고리즘을 구현하는 MessageDigest 객체를 생성한다.
MessageDigest md = MessageDigest.getInstance("SHA-256");
// str + salt 문자열을 UTF-8 인코딩하여 byte 배열로 변환한다. default = UTF-8
byte[] bytes = (str + salt).getBytes();
// 해시할 데이터를 MessageDigest 객체에 전달한다. update를 통해 여러번 호출하여 데이터를 단계적으로 입력할 수 있다.
md.update(bytes);
// digest 메서드를 호출하여 최종 해시 값을 계산한다.
byte[] hash = md.digest();
// 해시 값을 16진수 문자열로 변환하기 위해 StringBuilder 객체를 생성한다. StringBuilder는 String 보다 성능이 좋다.
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
// byte b를 2자리의 16진수 형식으로 변환한다. ex) b = 10 -> 0a
String format = String.format("%02x", b);
sb.append(format);
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
결론
이렇게 보면 SHA-256 해싱이 정말 좋아보인다.
하지만! SHA-256 보다 더 좋은 Bcrypt 해싱이 있다.
Bcrypt는 자체적으로 salt값을 생성하고 해시값에 포함시켜 관리하기 때문에 salt를 따로 관리할 필요가 없다.
또한 SHA-256은 매우 빠른 해시 알고리즘으로 설계되어있어 무작위 대입 공격(Brute-force Attack)에 취약할 수 있다.
Bcrypt는 해싱 과정에서 해싱을 여러 번 반복하는 작업을 포함하고 있으며, 이 반복 횟수를 비용 인자(cost factor)로 조절할 수 있다.
예시로 Bcrypt 해싱 레인보우 테이블을 만들어보자.
예시 상황
- 비밀번호: "123456"
- Salt: "V2dW6KaE/GTonNKcQBh0U."
- Cost Factor: 2 ($2a$02$)
Bcrypt는 이 비밀번호와 salt를 사용하여 (2^2) 4번의 해싱 작업을 수행하게 된다.
해싱 과정
1. 첫 번째 해싱
- 입력: "123456" + "V2dW6KaE/GTonNKcQBh0U."
- 해시 계산:
- 첫 번째 해싱 작업을 수행하여 임시 해시 값을 생성한다.
- 임시 해시 값 1: "abcd1234..."
2. 두 번째 해싱
- 입력: 임시 해시 값 1("abcd1234...") + "V2dW6KaE/GTonNKcQBh0U." (같은 salt)
- 해시 계산:
- 임시 해시 값 1과 동일한 salt를 사용하여 두 번째 해싱 작업을 수행한다.
- 임시 해시 값 2: "efgh5678..."
3. 세 번째 해싱
- 입력: 임시 해시 값 2("efgh5678...") + "V2dW6KaE/GTonNKcQBh0U." (같은 salt)
- 해시 계산:
- 임시 해시 값 2와 동일한 salt를 사용하여 세 번째 해싱 작업을 수행한다.
- 임시 해시 값 3: "ijkl9012..."
4. 네 번째 해싱
- 입력: 임시 해시 값3 ("ijkl9012...") + "V2dW6KaE/GTonNKcQBh0U." (같은 salt)
- 해시 계산:
- 임시 해시 값 3과 동일한 salt를 사용하여 네 번째 해싱 작업을 수행합니다.
- 최종 해시 값: "mnop3456..."
최종 해시 값
$2a$02$V2dW6KaE/GTonNKcQBh0U.mnop3456...
- 비밀번호: "123456" 일때
- Salt: "V2dW6KaE/GTonNKcQBh0U." 이면서
- Cost Factor: 2 ($2a$02$) 일때의
- 최종 해시값 : $2a$02$V2dW6KaE/GTonNKcQBh0U.mnop3456...
의 레인보우 테이블을 하나 얻었다.
이제 다른 비밀번호의 레인보우 테이블을 만들어보자.
- 비밀번호: "0000"
- Salt: "V2dW6KaE/GTonNKcQBh0U."
- Cost Factor: 12 ($2a$12$)
V2dW6KaE/GTonNKcQBh0U. 의 Salt 일때
2 ^ 12 (4096) 해싱 작업을 반복해서
비밀번호가 0000 일때 해시테이블을 만드는데
300ms ~ 400ms 정도 걸렸다.
자 이제 모든 Salt의 모든 비밀번호의 레인보우 테이블을 만들어보자.
총 salt 조합 수:
Base64에서 22자리의 각 자리가 64가지 경우의 수를 가지므로,
총 가능한 salt 조합 수 = 64 ^ 22 ≈ 21 ^ 32 ≈ 5.4 × 10 ^ 39
12cost의 비밀번호 해싱 시간:
각 조합에 대해 해싱하는 데 걸리는 시간은 약 0.3초
총 해싱 시간 계산:
총 해싱 시간 = 총 salt 조합 수 × 12cost의 비밀번호 해싱 시간
= 5.4 × 10 ^ 39 × 0.3 초 = 1.62 × 10 ^ 39 초
1년은 대략적으로 31,536,000 초이다.
총 해싱 시간 (연도) = 1.62 × 10 ^ 39 / 31,536,000 ≈ 5.13×10 ^ 31 연도
즉, 레인보우 테이블을 만드는게 불가능하다.
단방향 해싱이 필요하다면 Bcrypt를 사용하자.
'메모하는 습관 > 백엔드 스프링' 카테고리의 다른 글
캐싱 전략 (0) | 2024.11.11 |
---|---|
Redis 캐시 적용 해보기 (0) | 2024.09.11 |
낙관적락이 롤백되는 이유 (0) | 2024.09.11 |
Mybatis 동작 원리 (0) | 2024.08.28 |
Patch 메서드가 멱등이 아닌 이유 (0) | 2024.04.29 |