본문 바로가기

1.프로그래밍/Java

[Spring] Spring 비밀번호 암호화 SHA-256 ~ BCryptPasswordEncoder(MessageDigest, SHA-256, BCryptPasswordEncoder)

728x90
반응형

[Spring] Spring 비밀번호 암호화 SHA-256 ~ BCryptPasswordEncoder(MessageDigest, SHA-256, BCryptPasswordEncoder)

개인의 비밀번호는 매우중요하다.

사람의 습관은 쉽게 잊혀지지 않고, 항상 익숙한 패턴을 생활한다.


즉, 비밀번호도 개인마다 정형화 되어 있다는 뜻이다.
만약, 내가 개발한 서비스에서 비밀번호를 암호화 하지않고 DB에 저장하였다가, DB가 털리게 된다면?
다른 몇몇 사이트 사이트에서 그 비밀번호로 해당 유저를 이용하여 해킹에 위험에 노출되게 된다. 그래서 비밀번호 암호화는 항상 중요하다.


인증(Authentication) 과 해시(Hash)

인증 (authentication)

  • 자신이 누구라고 주장하는 주체(principal)를 확인하는 프로세스

비밀번호 저장 방법

  • 단순 텍스트 절대 금지
  • 단방향 해시 함수(one-way hash function)의 다이제스트(digest)

해시 (Hash)

  • 다양한 길이를 가진 데이터를 고정된 길이를 가진 데이터로 매핑한 값.
  • 해시의 값을 digest(다이제스트) 라고 한다.
  • 해시는 고정 길이 -> 원문이 손실된다.
    • 해시 값으로 원문 복원 불가능
  • 원문과 해시 값 사이에 선형적인 관계가 없다.

해시 함수 (Hash Function)

  • 임의의 길이를 갖는 임의의 데이터를 고정된 길이의 데이터로 매핑하는 단뱡항 함수

단방향 해시 함수 문제점

인식가능성

  • 동일한 메시지는 동일한 다이제스트를 갖는다.

속도

  • 해시 함수는 원래 빠른 데이터 검색을 위한 목적으로 설계된 것.
  • 해시 함수의 빠른 처리 속도로 인해 공격자는 매우 빠른 속도로 임의의 문자열의 다이제스트와 해킹할 대상의 다이제스트를 비교할 수 있다

단방향 해시 함수 보완하기

salt

  • 단뱡향 해시 함수에서 다이제스트을 생성할 때 추가되는 바이트 단위의 임의의 문자열
  • 특정 비밀번호의 다이제스트를 안다고 하더라도 salt가 추가되면 비밀번호 일치 여부 확인이 어려움
  • salt의 길이는 32바이트 이상이어야 추측하기 어려움

키 스트레칭 (key stretching)

  • 입력한 비밀번호의 다이제스트를 생성하고
  • 생성된 다이제스트를 입력 값으로 하여 다이제스트를 생성하고
  • 또 이를 반복하는 방법으로 다이제스트를 생성해서
  • 입력한 비밀번호를 동일한 횟수만큼 해시해야만 입력한 비밀번호의 일치 여부를 확인할 수 있는 방법

Salt를 이용하지 않은 SHA-256 인코딩

PasswordUtil


@Slf4j
public class PasswordUtils {

    private PasswordUtils() {
        throw new IllegalStateException("Utility class");
    }

    public static String simple(String rawPassword) {
        byte[] digest = null;
        try {
            digest = sha256WithoutSaltAndIterations(rawPassword);
        } catch (NoSuchAlgorithmException ex) {
            log.error("", ex);
        }

        return bytesToHex(digest);
    }

    private static byte[] sha256WithoutSaltAndIterations(String rawPassword) throws NoSuchAlgorithmException {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        digest.reset();
        return digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8));
    }

    public static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b: bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }

}

    @Test
    void test() {
        String password = "12345";
        List<String> hashes = new ArrayList<>();

        for (int i = 0; i < 3; i++) {
            hashes.add(PasswordUtils.simple(password));
        }

        for (String hash : hashes) {
            System.out.println(hash);
        }
    }

/*
5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5
5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5
5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5
*/

위의 방법은 salt를 이용하지 않고, SHA-256을 이용하여 단방향 해시 함수를 이용한 비밀번호 인코딩 코드이다.


기본 생성자는 private 접근제어자로 막아두고, public static 메서드만을 이용하여 조작하도록 만들었다.
또한, byte[]hex로 바꾸어 줬는데, 자바에서 byte 자료형의 범위는 -128 ~ 127이다. 맨 앞의 비트는 부호코드로 인식되기 때문에 이것을 방지하기 위해 16진수로 변환해 주었다.


하지만, Hash는 각 값에 동일안 digest를 반환한다.
그렇기에 여기서 이용하는 것이 salt이다.
랜덤한 byte 코드를 집어 넣어 다시한번 해싱하여 encoding 하는 것이다.

Salt를 이용한 SHA-256 인코딩


@Slf4j
public class PasswordUtils {

    private static final int DEFAULT_ITERATIONS = 1024;

    private PasswordUtils() {
        throw new IllegalStateException("Utility class");
    }

    public static String encode(String rawPassword, byte[] salt, int iterations) {
        byte[] digest = null;
        try {
            digest = sha256(rawPassword, salt, iterations);
        } catch (NoSuchAlgorithmException ex) {
            log.error("", ex);
        }

        return bytesToHex(digest);
    }

    private static byte[] sha256(String rawPassword, byte[] salt, int iterations) throws NoSuchAlgorithmException {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        digest.reset();     // reset() -> Resets the digest for further use.
        digest.update(salt);    // update() -> Updates the digest using the specified array of bytes.

        byte[] input = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8));
        for (int i = 0; i < iterations; i++) {
            digest.reset();
            input = digest.digest(input);
        }

        return input;
    }

    public static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b: bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}

    @Test
    void test2() {
        String password = "12345";
        List<String> hashes = new ArrayList<>();

        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[8];

        for (int i = 0; i < 10; i++) {
            random.nextBytes(salt);
            String digest = PasswordUtils.encode(password, salt);

            System.out.println("salt=" + PasswordUtils.bytesToHex(salt) + ", digest=" + digest);
            hashes.add(digest);
        }
    }

    /*
    salt=373c4f5a6b8a731e, digest=1c1e72899849337c84e819c82279ec6354425b683b532003abc4a776b45c9680
    salt=adc9250de51b49b0, digest=bde81522b3c5daefb50d83219ef859470d1e2cefbe66b249d4393b0a3deab7fb
    salt=9e4e16645c0c1a00, digest=5b6eaf2f66a4dc9712a554ecb200cac22497945496c1df5f8bb23f231e7346cb
    salt=10a3ef4f4a006af5, digest=ec4a2033bc5f8d5ac6b3f73524b5200091720af7b809b5652c651a426bbc036f
    salt=ca0df12395542469, digest=f9d055d8d0b7e82a20c411ba998eef03a7594c29b3a73135b63428122428c5a1
    salt=11a00bd6d8c69b38, digest=05450b64ac849d1c29ba7c3652ca12437481993ae37cc7980dd76ec04535b2c6
    salt=d218301b6f6a7ad4, digest=0ac37b2b560e333c546b979c190d37b93b464e9b84297be286cc1a72cb5f0990
    salt=bb37a3afdc0a9fad, digest=f4e73c0e5eab1fcbdaab30fc2f5a9e8705cf3a14df0a118dd171ffb641131e95
    salt=2e35c34977cd96a6, digest=5a87a42cb8cc0c782965f7f63440fc90d0d7c7e88a1953d916003235a8b9c5a5
    salt=33996d48f265c9fb, digest=e7faef4561caececbc1845c5320a8e76c4f272e80c915742d7e66163f459debf
    */

위는 Salt를 이용하여 인코딩하는 코드이다.
위의 테스트의 결과와 같이 랜덤한 salt를 이용하여 같은 String 값을 인코딩하여도 모두 다른 결과를 반환 받는 것을 볼 수 있다.
이렇게 하면 해커가 어떠한 String 값을 인코딩하였는지도 저장하기 어려워 진다.


DB에 저장시에는 salt 값과 digest값을 저장해 두고 사용하고, 만약 DB를 해커가 보게 되어도,
개발자가 지정한 Iteration 값을 모르기에 상당한 노고와 시간이 걸린다.
Iteration의 수가 높을수록 즉, 반복을 많이 할수록 raw값을 알기 더욱 힘들어 진다.

BCryptPasswordEncoder 인코딩

Spring Security Config Bean 주입

@EnableWebSecurity(debug = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    ...

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    ...

}

PasswordEncoder이라는 Spring Security의 인터페이스를 구현하고있는 BCryptPasswordEncoder로 받아서 Bean 주입을 해주었다.

Service


@Service
public class MemberService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    public MemberService(MemberRepository memberRepository,
                         PasswordEncoder passwordEncoder) {
        this.memberRepository = memberRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @Transactional
    public String createMember(MemberCreateRequest request) {
        Member member = new Member();
        member.setId(request.getId());
        member.setName(request.getName());
        member.setPwd(passwordEncoder.encode(request.getPwd()));

        memberRepository.save(member);

        return member.getId();
    }

    @Transactional
    public String matchMember(String userId, String rawPwd) {
        Member member = memberRepository.findById(userId).orElseThrow(NoSuchElementException::new);

        boolean matches = passwordEncoder.matches(rawPwd, member.getPwd());

        if (!matches) {
            throw new IllegalArgumentException();
        }

        return member.getId();
    }

}

위는 간단한 회원 등록과 로그인을 위한 메서드 이다.
위에서 사용한 메서드는 encode()matches() 메서드이다.
말 그대로 encode() 메서드는 raw한 값을 salt를 사용하여 인코딩하는 것이고,
mathches() 메서드는 raw한 값을 Iteration 하여, encoding 된 값과 일치하는 확인해주는 메서드이다. (return boolean)


스크린샷 2022-12-07 오후 3 01 45

위의 사진은 PasswordEncoder 인터페이스에 정의된 메서드 항목들이고, 각각의 구현체들에 더 많은 함수들이 있다.
직접 들어가 보고 필요한것을 사용하면 될 것 같다.


궁금증. 그렇다면 BCryptPasswordEncoder를 사용하면 Salt값은 ???

처음 Salt와 SHA-256를 이용하여 인코딩 하였을 때에는 분명 Salt값과 Digest값을 DB에 저장해야지만,
raw한 값을 이용하여 match를 알 수 있었다.


그런데 BCryptPasswordEncoder를 이용하여 비밀번호를 인코딩하면 인코딩 된 결과값만을 DB에 저장해 두고,
raw한 값을 salt없이 찾는 모습을 보여주었다.
심지어, BCryptPasswordEncoder내부 구현체를 찾아 들어가 확인해 보니, Random SaltRandom strength를 사용하여 encoding하고 있었다.


https://jhkimmm.tistory.com/m/24


이러한 궁금증을 잘 정리해둔 블로그가 있다.


결론은 salt 값과 strenght 값은 인코딩 값과 저장되는 것이다.


$2a$10$vI8aWBnW3fID.ZQ4/zo1G.q1lRps.9cGLcZEiGDMVr5yUP1KUOYTa

$2a는 Bcrypt의 버전을 뜻하고,
그 뒤에 $10cost factor로 key derivation 함수가 2^10만큼 진행 된다는 뜻이고, 그 뒤의 값들이 인코딩 된 값들이다.

이에 대해 Salt값이 노출되도 되는가 싶었지만, 결론적으로 완벽한 비밀은 존재하지 않고, Salt의 이용 의도는 해커들의 각종 Hash가 저장되어 있는 Rainbow Table의 이용을 막는 것이며, Salt값이 노출 되어도 새로운 Rainbow Table를 만든는것은 시간과 금전적 비용이 매우 크기에 그럴 걱정은 안해도 된단다.


이상으로 Spring에서 평문을 암호화는 방법에 대해 알아보았다.

728x90
반응형