[JCA] Hash 함수의 개요와 PBKDF2를 이용한 단방향 해시 알고리즘 구현

2022. 7. 27. 18:15Dev/Java

300x250
반응형
  1. Hash Algorithm
    1. Hash 함수?
    2. MessageDigest 알고리즘
    3. Avalanche effect
    4. MessageDigest의 단점
  2. MessageDigest 해시함수 보완 방법
  3. Adaptive Key Derivation Function
    1. PBKDF2
    2. bcrypt

1. Hash Algorithm

1.1. Hash 함수?

해시 함수(= 해시 알고리즘)

메시지 인증과 무결성 체크를 위해 이용됩니다.
단방향 암호 알고리즘이기 때문에 해시값을 복호화 할 수 없습니다.

원본 데이터의 내용이 같을 경우 동일한 해시값을 리턴하는 성질을 이용하여 데이터 무결성을 확인합니다.

단독으로 사용할 경우 처리 속도가 매우 빠릅니다.
매우 빠른 처리 속도는 공격자들의 무단 공격에 매우 취약한 단점이 됩니다. 이러한 이유로 비밀번호와 같이 강력한 보안이 필요한 부분에는 Key Derivation Function을 사용하는 것을 권장합니다.

해시 함수를 통해 암호화된 Hash값은 digest, Checksums, digital fingerprints 라고도 부릅니다.

원본 데이터(message)를 해시 알고리즘으로 암호화한 Hash 값을 digest라고 부릅니다.


1.2. MessageDigest 알고리즘

@SneakyThrows
static String encrypt(String message) {
    final MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
    messageDigest.update(message.getBytes());
    return new String(Base64.getEncoder().encode(messageDigest.digest()));
}
  • MD2
    • RFC 1319에 의해 정의된 MD2 Message Digest 알고리즘
  • MD5
    • RFC 1321에 의해 정의된 MD5 Message Digest 알고리즘
  • SHA-1, SHA-224, SHA-256, SHA-384, SHA-512, SHA-512/224, SHA-512/256
    • Secure Hash Algorithm
    • FIPS PUB 180-4 에 의해 정의된 보안 해시 알고리즘
    • SHA-1는 160bit digest를 생성합니다
    • SHA-224(224 bit), SHA-256(256 bit), SHA-384(384 bit), SHA-512(512 bit), SHA-512/224(224 bit), SHA-512/256(256 bit)
  • SHA3-224, SHA3-256, SHA3-384, SHA3-512
    • FIPS PUB 202에 정의된 확장가능한 출력기능을 갖는 순열 기반 해시 함수

1.3. Avalanche effect

아래는 "hello jiniworld" 문자열을 SHA-256으로 암호화한 결과입니다.
입력값이 같을 경우 동일한 해시를 만들기 때문에, "hello jiniworld"의 digest는 언제나 아래와 같습니다.

fBWgim+2DAp0+01x5Ylpr9pPFC7zbx5GMmofaw5+sGI=

여기에 느낌표만 하나 붙여서 "hello jiniworld!" 을 암호화해보니 아래와 같은 결과값이 리턴되었습니다.
"hello jiniworld" 의 해시값과 결과가 전혀 다른 것을 확인할 수 있습니다.

A7Z8clE8u2kBxXxYgArInfN49/VKiOjFXw5zXXvW74g=

이러한 특성을 Avalanche effect(산사태 효과)라고 말합니다.
입력값에 미세한 변화가 출력값에 상당한 변화를 주는 특성을 의미하고, 이 특성이 강할 수록 강력한 알고리즘임으로 의미합니다.


1.4. MessageDigest의 단점

rainbow attack

다만 해시 함수는 message 입력값이 같으면 암호화된 Digest가 동일하기 때문에,
digest값이 대량으로 탈취되었을 때 공격자가 Rainbow Table을 이용하여 원본 message를 추출할 수 있다는 취약점이 존재합니다.


빠른 변환 속도

해시 함수가 최초로 설계된 목적이 빠르게 데이터를 검색하기 위한 것인 만큼. 기본적으로 해시함수는 처리속도가 매우 빠릅니다.

MD5 알고리즘의 경우 일반적인 컴퓨터를 이용한다 하더라고 1초에 56억개 가략의 digest 추론이 가능하다고 합니다.
여기에다 원문의 길이가 짧고 단순할 경우 암호 원문 추론은 더 간단해집니다.

변환이 매우 빠른 부분은 어쩌면 공격하는 이들에게 더 유리한 부분이 있습니다.


2. MessageDigest 해시함수 보완 방법

2.1. salt

단방향 해시 알고리즘에서 digest를 생성할 때 이용하는 byte단위 문자열로, 외부에서 rainbow attack을 방지하기 위해 이용합니다.
해시 알고리즘 뿐만 아니라 추론하기 어려운 임의의 salt 문자열을 이용하기 때문에 digest가 탈취되어도 salt를 모르면 원문을 추론하기 어렵습니다.

보안을 강화하기 위해 암호화하고자 하는 원문 각각당 랜덤으로 생성한 salt를 갖고, 길이를 적정 수준으로 설정하는 것을 권장합니다.

2.2. key stretching

입력한 패스워드에 대한 digest를 생성하고, 그 digest를 입력값으로 이용하여 다시 digest로 만드는 행위를 N번 반복합니다.
반복하는 iteration count를 동일한 횟수만큼 해싱해야 비밀번호 일치여부를 확인할 수 있습니다.

brute-force attack(무차별 대입 공격)을 이용하여 password 추측을 쉽게 할 수 있는 것을 방지하기 위해 키 스트레칭을 이용하여 하나의 digest의 생성 시간을 0.2초 이상으로 설정하는 것을 권장합니다.

키 스트레칭은 보통 랜덤으로 생성한 salt값과 함께 이용합니다.
password와 salt를 이용하여 iteration count 만큼 해싱을 반복하여 최종 digest를 얻습니다.

01-8


3. Adaptive Key Derivation Function

salt와 key stretching을 적용한 강력한 암호화 함수를 알아봅시다.


3.1. PBKDF2

Password-Based Key Derivation Function

ISO-27001 보안 규정을 준수하고 있는 표준 암호화 알고리즘으로, 가장 많이 사용되고 있는 Key Derivation Function입니다.

PBKDF2WithHmacSHA1, PBKDF2WithHmacSHA256, PBKDF2WithHmacSHA512 알고리즘을 이용하여 암호화 키를 만드는데, 랜덤함수로 생성된 salt값과 password를 ITERATION_COUNT만큼 키스트레칭한 값을 SecretKey로 갖습니다.


3.1.1. Pbkdf2PasswordEncoder 이용

Spring Security 라이브러리에서 제공하고 있는 Pbkdf2PasswordEncoder를 사용하면 PBKDF2로 매우 간편하게 암호화할 수 있습니다.

먼저 의존성 라이브러리로 Spring Security를 추가해주고

dependencies {
    implementation 'org.springframework.security:spring-security-core:5.7.2'
}

Pbkdf2PasswordEncoder 객체를 생성한 후, 인코딩 설정 후 encode함수를 통해 암호화하고자 하는 원문을 암호화하면 됩니다.
decode는 되지 않으나, matches 함수를 통해 인코딩된 암호문과 raw문자열이 매칭되는지 확인할 수 있습니다.

별도로 설정하지 않을 경우 기본값인 PBKDF2WithHmacSHA1 알고리즘으로 설정됩니다.

인코딩과 디코딩을 Base64 방식으로 하고 싶다면 encodeHashAsBase64 값이 true로 설정하면 되고, Hex방식으로 하고 싶다면 false로 설정하면 됩니다. (기본값은 false)

이번 예제에서는 Base64 방식으로 인코딩할 것이기 때문에 line 13에서 encodeHashAsBase64값을 true로 설정했습니다.

import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;

public class Pbkdf2PasswordEncoderExample {
    private static final CharSequence SECRET_KEY = "mySecretKey1123!";
    private static final int ITERATION_COUNT = 65536;
    private static final int HASH_WIDTH = 256;
    private static final int SALT_LENGTH = 16;
    public static Pbkdf2PasswordEncoder pbkdf2PasswordEncoder;

    static {
        pbkdf2PasswordEncoder = new Pbkdf2PasswordEncoder(SECRET_KEY, SALT_LENGTH, ITERATION_COUNT, HASH_WIDTH);
        pbkdf2PasswordEncoder.setAlgorithm(Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256);
        pbkdf2PasswordEncoder.setEncodeHashAsBase64(true);
    }

    public static void main(String[] args) {
        String rawPassword = "pass0rd2213@";
        String encPassword = pbkdf2PasswordEncoder.encode(rawPassword);
        String encPassword2 = pbkdf2PasswordEncoder.encode(rawPassword);
        System.out.println(encPassword);
        System.out.println(encPassword2);
        System.out.println(pbkdf2PasswordEncoder.matches(rawPassword, encPassword));
        System.out.println(pbkdf2PasswordEncoder.matches(rawPassword, encPassword2));
    }
}
PwFaWOHBeetzCFKy+U8fZg4cxCTZlHNu6rngeYhYl9+QAXHClbP5Q+dC2X+9fJOQ
XH/cJ8LGYq4T9vx0PnBjwOHpDzKXa3WzyVC7o/Z0awHEi7vCkt557hPQRka843P1
true
true

※ 참고로, Hex 방식으로 인코딩 했을 경우 아래와 같이 최종 digest가 출력됩니다.

31625a3c9cc0a68ed1a13dd71581d3ea3683bdf8df53282307a088c63808c95d2d26f7710180ff6597d440416df6cbd0
7ff74fdb976be9c030a8d8d8ccddf7767aa3e25eb027c69e81e065667fdcd44a4099119d20e1ae85ad63b9f703bfea3c
true
true

3.1.2. SecretKeyFactory, PBEKeySpec 직접 정의

PBKDF2 암호화 관련 설정을 위와 동일하게 설정하여 SecretKeyFactory와 PBEKeySpec을 직접 정의해보았습니다.

관련 코드는 Spring Security의 Pbkdf2PasswordEncoder를 참고하였습니다.


01-11

rawPassword 문자열과 salt를 이용하여 encode합니다.
encode할때에는 SECRET_KEY와 키스트레칭 반복횟수(ITERATION_COUNT), 해시길이(HASH_WIDTH) 등의 정보도 함께 정의해줍니다.
이때, salt값은 랜덤값으로 생성되기 때문에 별도로 저장되어야만 암호 매칭이 가능하게 됩니다.

이러한 이유로 encode된 값에 salt값을 결합하여 암호를 저장하는 것을 권장합니다


암호문의 byte array 변환한 값에서 SALT_LENGTH만큼 substring 하여 salt 부분과 digest 값을 구분하여 확인할 수 있습니다.

private static final SecureRandom random = new SecureRandom();
private static final byte[] SECRET_KEY = Utf8.encode("mySecretKey1123!");
private static final int ITERATION_COUNT = 65536;
private static final int HASH_WIDTH = 256;
private static final int SALT_LENGTH = 16;

public static String encode(CharSequence rawPassword) {
    byte[] salt = createSalt();
    byte[] encoded = encode(rawPassword, salt);
    return Base64.getEncoder().encodeToString(encoded);
    // return String.valueOf(Hex.encode(encoded)); // Hex 방식 인코딩
}

private static byte[] encode(CharSequence rawPassword, byte[] salt) {
    try {
        PBEKeySpec spec = new PBEKeySpec(rawPassword.toString().toCharArray(),
                concatenate(salt, SECRET_KEY), ITERATION_COUNT, HASH_WIDTH);
        SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        return concatenate(salt, skf.generateSecret(spec).getEncoded());
    } catch (GeneralSecurityException ex) {
        throw new IllegalStateException("Could not create hash", ex);
    }
}

public static boolean matches(CharSequence rawPassword, String encodedPassword) {
    byte[] digested = Base64.getDecoder().decode(encodedPassword);   
    // byte[] digested = Hex.decode(encodedPassword);  // Hex 방식 디코딩
    byte[] salt = new byte[SALT_LENGTH];
    System.arraycopy(digested, 0, salt, 0, SALT_LENGTH);    // deep copy
    return MessageDigest.isEqual(digested, encode(rawPassword, salt));
}
static byte[] createSalt() {
    byte[] salt = new byte[SALT_LENGTH];
    random.nextBytes(salt);
    return salt;
}

public static byte[] concatenate(byte[]... arrays) {
    int length = 0;
    for (byte[] array : arrays) {
        length += array.length;
    }
    byte[] newArray = new byte[length];
    int destPos = 0;
    for (byte[] array : arrays) {
        System.arraycopy(array, 0, newArray, destPos, array.length);
        destPos += array.length;
    }
    return newArray;
}

위의 함수의 출력결과입니다.

public static void main(String[] args) {
    String rawPassword = "pass0rd2213@";

    String encString = encode(rawPassword);
    String encString2 = encode(rawPassword);

    System.out.println(encString);
    System.out.println(encString2);
    System.out.println(matches(rawPassword, encString));
    System.out.println(matches(rawPassword, encString2));
    System.out.println(matches(rawPassword, "PwFaWOHBeetzCFKy+U8fZg4cxCTZlHNu6rngeYhYl9+QAXHClbP5Q+dC2X+9fJOQ"));
}
bpWH7nHCTJMPpUFCGe7T3t2g7SkJLFNxXRAyE2Jy2kbk+QzeDYE4gN03RSlWOEXv
c0dJdjYsTPYS07QhejpjiZGNQ8oFRILSi0hYNly6PfSBQCPQDGMr+ai+qW356Ql3
true
true
true

3.2. bcrypt

Blowfish 블록 암호화 알고리즘을 해시함수로 변형한 것으로, 비밀번호 저장을 목적으로 설계되었습니다.

Bcrypt 알고리즘은 JCA에서 기본적으로 제공되지는 않고, Spring Security 라이브러리에서 제공하고 있어, 라이브러리를 추가하면 매우 간편히 사용할 수 있습니다.

01-10

bcrypt 알고리즘의 digest는 bcrypt 알고리즘 버전, 키스트레칭 횟수, salt, hash 를 합친형태로,
암호화할때마다 salt값이 바뀌기 때문에 암호화할때마다 hash및 전체 digest값이 바뀝니다.

encode 된 결과끼리는 서로 일치하는지 같은 문자열을 암호화한 결과끼리는 매칭 검사 할 수 없고
원문과 암호화된 결과의 매칭 확인은 가능합니다.

dependencies {
    implementation 'org.springframework.security:spring-security-core:5.7.2'
}

아래 코드는 BCryptPasswordEncoder을 이용하여 간단하게 BCrypt 암호화를 테스트 해보았습니다.

참고로 BCryptPasswordEncoder 생성자에 파라미터를 설정하지 않으면 기본적으로 Bcrypt 알고리즘 버전은 $2A, 키스트레칭 횟수는 10입니다.

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class BcryptExample {

    public static final BCryptPasswordEncoder bCryptPasswordEncoder =
            new BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion.$2B, 12);

    public static void main(String[] args) {
        String rawPassword = "password2213@";
        String encPassword = bCryptPasswordEncoder.encode(rawPassword);
        String encPassword2 = bCryptPasswordEncoder.encode(rawPassword);
        System.out.println(encPassword);
        System.out.println(encPassword2);
        System.out.println(bCryptPasswordEncoder.matches(rawPassword, encPassword));
        System.out.println(bCryptPasswordEncoder.matches(rawPassword, encPassword2));
    }
}

암호화된 결과는 다르지만 rawPassword와 암호화된 값끼리는 매칭확인할 수 있습니다.

$2b$12$KlTV56khz1kIDSXU/YHrxeaeEffOCn.J57Ys2hlhi3Cx3HZkAj.Bi
$2b$12$Ut6zyHF0uvMn6kRIJunw0ewax2YqypjTk17tP930exx5fGNmj7EBO
true
true

+++ References

300x250
반응형
  • 프로필사진
    헬프 ㅜㅜ2022.09.08 10:35

    현재 방식 SHA-256으로 되어있는거같은데 제가 원하는건 SHA-512로 isEqual 쓰고싶은데 어떻게 바꿔야할까요 ㅜㅜ

    • 프로필사진
      Favicon of https://blog.jiniworld.me BlogIcon jiniya222022.09.10 21:10 신고

      SHA-512 알고리즘을 사용하고 싶다면, 알고리즘 타입에 SHA-256 대신 SHA-512로 변경하면 됩니다.

      `MessageDigest.getInstance("SHA-512";);`