메모하는 습관/백엔드 스프링

SHA-256 핵심, 요약, java 구현, 결론

rotomoo 2024. 8. 28. 11:41

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

굉장히 어려운 문서내용.

굳이 해당 내용을 다 알고있을 필요는 없다.

복잡한 해시 알고리즘을 통해 해싱 한다는것만 가져가면 된다.

ref. https://ko.wikipedia.org/wiki/SHA

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 해시로 완전히 바뀌었다.

 

ref. https://ko.wikipedia.org/wiki/%EC%86%94%ED%8A%B8_(%EC%95%94%ED%98%B8%ED%95%99)

 

나아가 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,0005.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