상세 컨텐츠

본문 제목

[패스워드 암호화 저장] Hash, Salt

자바

by esoesmio 2023. 3. 23. 10:03

본문

'보안은 그 어느 시스템의 정보보다 가장 중요하며 가장 안전해야 하는 것이다'

 

 

필자가 "프로그래머로써 가장 중요하게 생각해야 할 것 하나만 뽑는다면?" 이라는 질문이 들어온다면 위와 같이 답할 것이다.

 

현재 인터넷 보급률이 전 세계적으로 50%를 넘어섰다. 그 중 한국만 하더라도 인터넷 보급률은 거의 98%에 달한다고 한다. 이 말은 사실상 몇명을 뺀다면 인터넷을 이용하지 않는 사람이 없다고 봐도 무방하지 않을까.

 

이러한 인터넷 사용에 있어 우리는 기본적으로 개인정보를 안전하게 보관될 것이란 신뢰를 바탕을 두고 있어야 하고, 이 신뢰는 개발자가 짊어지는 가장 큰 무게일 것이다.

 

여러분들이 A은행을 이용하고자 하는데 A은행이 여러차례 개인정보가 유출 된 적이 있다고 하면 A은행을 이용하려 하지 않을 것이다. 대부분은 A은행의 정보 암호화에 대한 신뢰가 떨어져 있기 때문에 약간의 손해를 감수하더라도 다른 대안 은행을 찾아 이용하려 할 것이다.

 

이렇듯 기본적으로 어떤 서비스를 가입하여 이용할 때 우리는 기본적으로 '개인'만 접속, 이용할 수 있도록 권한을 제한하고, 만약에 개인정보를 저장하고 있는 DB가 해커들에 의해 털렸다고 하더라도 해독하지 못하도록 만든다.

 

 

예로들어 어떤 게임을 이용하기 위해 가입을 해본다고 하자.

 

먼저 본인인증을 할 것이다. 이 인증과정에서 여러분은 이름, 휴대전화번호, 주민등록번호 등 다양한 과정들을 통해 인증할 것이다. 그래야 만일 나이제한이 있는 게임에 해당 나이를 만족하지 못하는 사람이나, 타인을 사칭하여 가입하여 악의적 목적으로 이용되는 것을 1차적으로 방지 할 수 있다.

 

이렇게 본인인증이 완료되면 아이디를 검색하여 '중복검사'를 하고, 패스워드를 입력하라고 할 것이다.

이 과정에서 패스워드는 대부분의 사이트의 경우 "몇 자리 이상, 영어, 특수문자 포함" 등 최대한 알기 어렵게 만들라고 요구한다.

 

마지막으로 이메일을 입력하고, 여러가지 동의서 전문을 읽은 뒤(사실상 스크롤만 하지만..) 모두 동의를 누르면 그제서야 게임 아이디를 만들게 된 것이다.

 

이 과정이 다소 불편하다고 느낄 것이지만, 겉으로 보이는 것이 그정도일 뿐 내부에서는 여러분이 입력한 개인정보들을 훨씬 더 복잡하게 암호화하여 안전하게 보관하도록 하고 있다. 이렇게 당신의 개인정보는 안전하다는 신뢰를 얻기 위해 개발자들은 수많은 노력을 기울인다.

(물론 안전하지 않은 곳들도 꽤 많고, 지금도 많은 해커들은 열심히 당신의 개인정보를 탈취하려 노력할 것이다.)

 

 

이렇게 인터넷 서비스에서 빠질 수 없는 것이 바로 개인정보와 암호화고, 이러한 시스템이 만약에 없었다면 지금만큼의 인터넷 보급률이 나올 수도 없었을 것이다.

 

또한 법적으로도 개인정보에 대해 법률로 정하고 있고, 최소 기준도 제시하고있다.

 

개인정보의 안전성 확보조치 기준 ( http://www.law.go.kr/행정규칙/개인정보의안전성확보조치기준 )

 

 

 

 

어쩌면 빼놓을 수 없는 숙명이랄까.. 여러분들이 언젠가 개발자가 된다면 "비밀번호 시스템"을 구현해볼 일이 생길 것이다.

 

 

그리고 글을 이어나가기에 앞서 여러분이 꼭 알아두셨으면 하는 것이있다. 해싱과 암호화는 일상에서는 어느정도 통용되지만, 암호학적으로 본다면 차이가 있다. 가장 큰 차이는 '방향성'이다. 단방향, 즉 복호화가 불가능하는 하다는 것이고 이를 '해싱'이라 부른다. 반면에 '암호화(Encryption)'는 해싱하고는 다르다. '암호화' '양방향'이다. 즉, 암호화를 하면 역으로 복호화도 가능한 것이다. 

 

이번 포스팅에서는 정확하게는 양방향이 아닌 단방향, 즉 해싱에 대해 알아보고 간략하게 Java로 구현을 해보기로 한다.

 

 

 

 

 


 

 

 

 

 

 

  • 단방향 해시 함수 ( One-Way Hash Function )

 

 

 

 

대부분의 개발자들은 단방향 해시를 기본 골자로 하여 안전하게 패스워드를 저장하려 할 것이다.

 

 

만약 평문으로 저장한다? 이는 범죄행위를 대놓고 유도하는 것이나 마찬가지다..

혹여 그렇게 사용하고 있거나 발견하게 된다면 당장 바꾸도록 해야한다.

 

(그런데 기어코 작년에 페이스북에서 그 일을 저지르고야 말았다... 궁금하시다면 아래 기사를..)

 

 

 

 

단방향 해시 함수는 어떤 수학적 연산(또는 알고리즘)에 의해 원본 데이터를 매핑시켜 완전히 다른 암호화된 데이터로 변환시키는 것을 의미한다. 이 변환을 해시라고 하고, 해시에 의해 암호화된 데이터를 다이제스트(digest)라고 한다.

 

또한 앞서 말했듯 해싱은 단방향이다. 한마디로 단방향 해시 함수는 다이제스트를 복호화, 즉 원본 데이터를 구할 수는 없어야 한다. 말 그대로 단방향성이다.

 

 

세계에서 가장 인기있는 비밀번호인 123456을 예로들어보자.

간단하게 그림으로 설명한다면 다음과 같다.

 

 

즉 원본 메시지 123456 을 해시 함수에 돌려서 다이제스트인 fs32a3xzz0 을 생성하고 해당 데이터를 DB 에 저장하는 것이다.

.

이렇게 저장된 다이제스트는 설령 DB가 털린다 하더라도 fs32a3xzz0 은 단방향으로 해싱 된 문자라 복호화 할 수가 없는 것이다. 또한 의미를 파악할 수도 없다.

 

 

이러한 단방향 해시 함수의 종류들은 매우 많다. 대표적으로 아래와 같은 알고리즘들이 있다.

 

 

그중 가장 대표적인 해시 알고리즘인 SHA-256 을 통해 123456 을 해싱하면 다음과 같이 나온다.

8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92

 

 

 

만약 조금만 변경하여 123456 다음에 마침표(.) 하나만 더 찍어도 완전히 다른 값이 나온다.

43fae6c11d7632acc6059de1cced9b09a58caaa878071308ad67f32ef6b11691

 

 

 

이렇게 사용자로부터 입력받은 정보를 그대로 저장하는게 아니라 해싱을 해서 저장하는 것이다.

그러면 DB를 털어 저런 값을 얻었다고 한들 기존 원래 패스워드를 유추하기 힘들게 된다.

 

 

 

 

 

 


 

 

 

 

 

  • 단방향 해시 함수의 한계점

 

 

 

저렇게 SHA-256 같은 해시 함수를 이용하여 해싱 한 데이터를 DB에 저장하면 매우 안전할 것 같지만 현실은 그렇지 않다. 이에 대한 이유와 문제점들을 같이 짚어보도록 하자.

 

 

 

 

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

 

앞서 위의 123456 을 SHA-256 을 통해 다이제스트를 얻었다. 분명 123456 의 다이제스트는 원래의 password 인 123456 을 유추하기 어렵다. 그러나 123456 에 대한 다이제스트는 항상 같은 값을 얻는다는 것, 즉 값이 변하지 않는 것이 큰 문제점이다.

 

여러분이 해커(공격자)라고 가정해볼 때 해싱된 메시지의 원문을 얻기 위해서 가장 편한 방법은 무엇일까?

 

그 것은 바로 그동안 해커들이 여러 값들을 대입해보면서 얻었던 다이제스트들을 모아놓은 리스트에서 찾아보는 것이다. 이러한 다이제스트들의 테이블을 우리는 레인보우 테이블(Rainbow Table)이라고 한다.

 

구글에 'sha-256 rainbow table' 라고 검색해보면 여러 사이트가 있다.

 

 

우리가 123456 은 모른다고 가정하에 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92 만 갖고 한 번 찾아보자.

 

sha-256 rainbow table 라고 구글링하여 한 사이트에 해당 해시값을 넣고 돌려봤더니 1초도 안되어서 Type 과 결과까지 나온다.

 

 

 

 

이렇게 사용자들이 많이 사용하는 password 나 복잡하지 않은 암호의 경우 이미 공격자들이 대입해보았을 확률이 높다. 그리고 대입해 볼 확률이 높다는 것은 즉, 이미 해당 패스워드의 다이제스트가 레인보우 테이블에 있을 가능성이 높다는 것이다.

 

 

 

 

 

 

2. 무차별 대입 공격 (브루트포스)

 

 

해시 함수의 경우 원래 빠른 데이터 검색을 위한 목적으로 설계된 것이다. 그렇다보니 해시 함수를 써도 원문의 다이제스트는 금방 얻어진다. 바로 이 점이 문제점인데, 우리가 다이제스트를 빠르게 얻을 수 있는 것과 동일하게 해커도 똑같이 빠르게 값을 얻을 수 있다는 것이다. 즉, 해커는 무작위의 데이터들을 계속 대입해보면서 얻은 다이제스트와 해킹할 대상의 다이제스트를 계속 비교를 해보는 것이다.

 

해시를 통한 다이제스트 값을 빠르게 얻을 수 있는 만큼 해커입장에서는 좋을 수 밖에 없다.

 

 

물론 모든 값을 다 대입하기는 시간적으로 여유가 없다.

그럼 SHA-256 을 완전히 해독하려면 얼마나 걸릴까?

StackOverFlow 커뮤니티 글을 찾아보니 라데온 Radeon HD5830 에서 초당 대략 6억번의 SHA-256 을 수행할 수 있다고한다.

 

그냥 1초당 10억 번 이라고 가정해보자.

256 개의 비트가 있다는 의미이므로 경우의 수는

2256 = 115,792,089,237,316,195,423,570,985,008,687,907,853,269,984,665,640,564,039,457,584,007,913,129,639,936

 

무려 78자리의 수다.

음.. 어떻게 읽어야 하나.. 여튼 이렇게 많다!

 

1초당 10억번이고, 하루 24시간을 초로 계산하면 86400 이므로 10억×86400 = 86조 4000억이다.

1년을 365일로 생각하면 3경1536조다.

 

풀어쓰자면 31,536,000,000,000,000 이다.

 

자 그러면 2256 / 31,536,000,000,000,000 을 해보자.

결과 값은 다음과 같다.

 

3,671,743,063,080,802,746,815,416,825,491,118,336,290,905,145,409,708,398,004,109 년이 걸린다.

 

우주의 나이가 대략 137억년이라고 한다.. 

 

 

 

이러한 경우의 수 덕분에 사실 모든 임의의 문자열을 모두 대입해 볼 수는 없다.

다만 사람은 누구나 자신이 기억하기 쉽도록 '상징성'이 있는 문자를 대입한다거나, 이해하기 쉬운 비밀번호를 사용하기 마련이다. 그래야 사용자도 비밀번호를 잊어먹지 않으니깐.. 어쩌면 당연할 수도 있다. (그러니 최대한 각 사이트별로 비밀번호를 조금이나마 다르게 만드는 것을 추천한다.)

 

그러나 이는 역으로 해커들도 일정 문자들을 추려 조합해보는 방식으로 해본다면 그리 어렵지 않게 해킹하려는 다이제스트의 원문을 쉽게 얻을 수 있다는 것이 바로 단점이다.

 

그리고 해시충돌에 대해 잠깐 언급해보자면 수학적 연산을 통해 데이터를 임의로 변형하여 얻어내는 것이기 때문에 두 개의 입력에 대해 동일한 값을 얻게 되는 경우도 있다. 이를 바로 해시충돌(Hash collision)이라고 하는데, 해시충돌을 발견하면 이를 이용하여 위조된 데이터로 바꿔치기가 가능하다. 사실 브루트포스로 모든 수를 대입할 필요가 없는게 생일 문제(birthday problem), 혹은 생일 역설(birthday paradox)이라는 걸 한 번쯤은 들어보셨을 것이다. 한 반에 생일이 같은 사람이 있을 확률에 대한 내용인데, 이 내용은 다음 링크를 한 번 참고해보시면 된다.(https://ko.wikipedia.org/wiki/생일_문제)

이를 고려해보면 확률적으로 매우 높은 확률로 해시충돌을 찾을 수 있다. (물론 그럼에도 찾기 힘들긴 하지만... 실제로도 SHA-1의 경우 2000년대 초에 많이 사용하던 대표적인 해시 알고리즘이었지만 SHA-1을 분석해 이론적으로 해시충돌을 찾는 복잡도를 낮췄고 2017년 실제로 해시충돌 발견에 성공했다. 그래서 많은 곳들이 SHA-2 이상의 알고리즘으로 변경되면서 사실상 거의 퇴출되었다.

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

  • 단방향 해시 함수 보완하기

 

 

 

 

 

저렇게 SHA-256 같은 해시 함수를 이용하여 해싱을 한들 100% 안전하다 말할 수는 없다는 것을 보았다. 그러면 어떻게 더 보완할 수 있을까?

 

가장 유명한 방법으로는 키-스트레칭과 솔트가 있다. 한 번 같이 알아보고 어떻게 적용되는지 보도록하자.

 

 

 

 

 

1. 해시 함수 여러 번 수행하기 [키 스트레칭 _ Key Stretching]

 

우리가 패스워드를 저장할 때 가장 쉽게 생각 할 수 있는 방법이다.

 

예로들어 SHA-256 을 사용한다고 가정할 때, 123456 이 입력되었다면 123456 의 다이제스트는 아래와 같았다.

8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92

 

 

이 다이제스트를 한 번 더 SHA-256 에 돌리는 것이다.

그러면 아래와 같은 값이 나온다.

49dc52e6bf2abe5ef6e2bb5b0f1ee2d765b922ae6cc8b95d39dc06c21c848f8c

 

 

물론 돌리는 횟수는 개발자 본인만 알고있는 것이 최고지만, 설령 소스파일을 들여다보았다 하더라도 최종 다이제스트의 원문 메시지를 얻기 위해 소모되는 시간은 더욱 많이 소요되므로 해커 입장에서는 곤란해지게 된다.

 

 

또한 해시 함수를 여러번 돌리는 만큼 최종 다이제스트를 얻는데 그만큼 시간이 소요되기 마련이다.

 

 

 

 

사용자의 경우 패스워드를 입력하고 일치여부를 확인 할 때 0.2 ~ 0.5 초만 걸려도 크게 문제가 없다. 그러나 앞서 말했듯이 임의의 문자열을 무차별 대입하는 해커 입장에서는 1초에 10억번의 다이제스트를 얻을 수 있었으나 다이제스트를 얻기 까지의 시간을 지연시켜 이제는 한 횟수당 0.2 ~ 0.5초가 걸리기 때문에 매우 치명적이다.

 

즉, 브루트포스를 최대한 무력화 하기위한 방법인 것이다.

 

 

 

 

 

 

 

 

 

2. 솔트 (Salt) 

 

그럼에도 뭔가 아직까지 부족한 것 같다. 여러번 돌리더라도 결국 몇 번 돌렸는지 횟수만 알면 상징성 있는 대표 문자열들을 추려서 대입해보면 적어도 공격하는 입장에서는 조금이나마 찾는데 시간을 줄이고, 각 횟수별 다이제스트가 Rainbow table 에 있을 확률이 높기 때문이다. 또한 같은 비밀번호를 사용하는 사용자들이 있다면 하나의 결과를 갖고도 다수 사용자의 password 를 알아내는 것이나 마찬가지다. 이를 방지하기 위해 도입한 것이 바로 솔트다.

 

솔트란 해시함수를 돌리기 전에 원문에 임의의 문자열을 덧붙이는 것을 말한다. 단어 뜻 그대로 원문에 임의의 문자열을 붙인다는 의미의 소금친다(salting) 는 것이다.

 

 

 

이렇게 하면 설령 다이제스트를 알아낸다 하더라도 password 를 알아내기 더욱 어려워진다. 그리고 사용자마다 다른 Salt 를 사용한다면 설령 같은 비밀번호더라도 다이제스트의 값은 다르다. 이는 결국 한 명의 패스워드가 유출되더라도 같은 비밀번호를 사용하는 다른 사용자는 비교적 안전하다는 의미기도 하다.

 

 

예로들어보자.

사용자1과 사용자2가 123456 이라는 같은 password 를 사용하고 있다.

하지만 사용자1은 솔트 값이 sffs13osz043opq9dsfdkj32 이고, 사용자2는 osela31dif3298hcwaw8s301 이다.

 

즉 사용자1이 해시함수를 돌리기 전에 솔팅된 문자열은 123456sffs13osz043opq9dsfdkj32 이고, SHA-256 에 돌리면 다음과 같은 값을 얻을 수 있다.

343099b2867417f1b60462a8c70aa9dc33f4b1cec287fdb22e9fcf9b999ee3ce

 

 

사용자2의 경우 해시함수를 돌리기 전 솔팅된 문자열은 123456osela31dif3298hcwaw8s301 이다.

이를 SHA-256 을 사용하여 해싱 하면 다음과 같은 값을 얻는다.

725c8c66c181855dd578961d90b2a051a250b232ede85a7ab5da5d0d4586d135

 

 

 

 

즉, 같은 패스워드를 사용하더라도 salting 된 문자열은 서로 다르기 때문에 각 사용자의 다이제스트는 서로 다른 값으로 저장될 것이다.

 

 

해커가 123456 의 다이제스트를 갖고 있다고 하더라도 레인보우테이블에서 비교하기 어렵게 만드는 것이다.

(123456 의 SHA-256 을 사용한 다이제스트는 아래와 같았다.)

8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92

 

더욱이 솔트는 임의의 문자열이기 때문에 Rainbow Table 에 없을 가능성이 매우 높아진다.

물론 솔팅할 문자열이 간단하거나, 짧으면 큰 의미는 없어진다. 가장 효과적인 방법을 구축하려면 적어도 각 사용자별 고유의 솔트를 갖고있어야 하며 솔트의 길이는 32비트 이상이 되어야 솔트와 다이제스트를 추측하기 어렵다고 한다. 그렇기에 솔트의 경우 암호학적으로 안전한 난수 생성기를 사용하여 예측가능성을 줄여야 한다.

 

 

설령 사용자의 고유 솔트를 알았다고 한들, 해당 솔트와 결합하여 임의의 문자열을 무차별 대입을 해보아야 하기 때문에 공격하는 사람 입장에서는 곤란하게 만들 수 밖에 없다.

 

솔트의 가장 큰 목적은 해당 솔트의 레인보우 테이블 새로 생성하여 만들기 위해서는 엄청나게 큰 데이터를 필요로 하기 때문에 자연스럽게 레인보우 테이블 생성을 방지하는 역할을 해주기도 한다.

 

 

 

이렇게 두 가지 보완 방법을 알아보았다. 위 두 가지 방법을 혼용하면 다음과 같을 것이다.

매 해시 함수를 사용할 때마다 salt 값을 추가하여 붙인다.

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

  • 구현해보기_Java

 

 

 

 

사용자의 패스워드를 안전하게 해싱하여 저장하는 방법을 알아보았다. 이를 토대로 이해하기 쉽도록 매우 간결하게 구현해보고자 한다. 사용할 언어는 Java 로 구현할 것이다.

 

 

 

 

1. DB

 

DB 를 가장하여 임의적으로 만든 클래스다.

데이터는 UserData 라는 ArrayList 객체에 저장할 것이고, 유저의 아이디 해싱된 패스워드(다이제스트), 유저 고유의 솔트를 저장하는 클래스다. 즉, 여러분이 입력한 패스워드를 평문으로 저장하는 것이 아닌, 해싱 된 패스워드를 저장하기 때문에 개발자가 악의적으로 데이터를 뽑아서 팔아 넘기려 하더라도 원래의 비밀번호는 알기 힘들다.

물론 ArrayList 를 사용하지 않고, 특정 파일에 읽고 쓰는 방법도 있다. 일단 여러분이 보기 편하도록 ArrayList 를 쓰겠다. (보통은 Map 구조 형식으로 자주 구현되긴 한다.)

 

 
import java.util.ArrayList;
 
 
 
public class DB {
 
 
 
private static ArrayList<String[]> UserData = new ArrayList<>();
 
 
 
 
 
// 유저 생성
 
public void set_USER(String ID, String Hasing_Password, String SALT) {
 
String[] temp = {ID, Hasing_Password, SALT};
 
UserData.add(temp);
 
}
 
 
 
// 들어온 ID 와 비밀번호가 일치하는지 체크
 
public boolean check(String ID, String Hasing_password) {
 
for(int i = 0; i < UserData.size(); i++) {
 
if(ID.equals(UserData.get(i)[0])) { // ID 일치하는 것을 찾을경우
 
if(Hasing_password.equals(UserData.get(i)[1])) { // 다이제스트도 일치할 경우 true
 
return true;
 
}
 
}
 
}
 
return false;
 
}
 
 
 
// 해당 ID 의 SALT 값 찾기
 
public String get_SALT(String ID) {
 
String err = null; // 아이디가 존재하지 않을 경우 null 리턴
 
for(int i = 0; i < UserData.size(); i++) {
 
if(ID.equals(UserData.get(i)[0])) {
 
return UserData.get(i)[2];
 
}
 
}
 
return err;
 
}
 
 
 
 
 
@Override
 
public String toString() {
 
StringBuilder sb = new StringBuilder();
 
for(String[] temp : UserData) {
 
sb.append("ID : " +temp[0] + "\tPassword : " + temp[1] + "\tSALT : "+ temp[2]).append("\n\n");
 
}
 
return sb.toString();
 
}
 
 
 
}

 

 

 

[set_User]

이후에 만들 클래스에서 유저의 아이디와 해싱한 비밀번호, 솔트를 받아 String 배열에 각각 넣어준뒤, 해당 배열을 UserData 에 넣어주는 역할을 한다.

 

 

[check]

사용자가 로그인 하고자 할 때 입력받은 아이디와 해싱된 패스워드가 UserData 에 저장된 유저의 정보와 일치할 경우 true 를 내보내고, 아닐경우 false 를 내보내는 역할을 한다.

 

 

[get_SALT]

로그인 할 때 우리는 아이디와 비밀번호를 입력한다. 이 때 해당 아이디의 솔트를 갖고와서 입력한 비밀번호를 해시 함수에 돌리고, 얻어진 다이제스트를 UserData 에 있는 해당 아이디에 대응되는 비밀번호 다이제스트와 일치여부를 알아보아야 한다. 그렇기 때문에, 해당 아이디의 SALT 를 찾게 해주는 역할을 한다. 만약 일치하지 않는 아이디의 경우 null 을 보내서 해싱하더라도 로그인을 실패하도록 한다.

 

 

[toString]

이후에 제대로 UserData에 아이디와 해싱된 패스워드, 솔트가 잘 저장되어있는지 알아보기 위해 썼다.

 

 

 

 

 

 

 

 

2. User

 

유저가 입력한 아이디와 비밀번호를 데이터를 가공(해싱)하여 새로운 계정을 생성하거나, 로그인할 수 있도록 하는 클래스다.

기본적으로 임의의 문자열인 SALT 는 128비트, 즉 16바이트로 고정시킬 것이기 때문에 SALT_SIZE 를 상수로 하여 16의 값을 갖는 불변정적변수로 생성한다. 그리고 DB 클래스 객체 또한 하나의 DB 를 공유해야 하기 때문에 정적객체로 생성한다.

 

추가로 SALT 를 생성하기 위해 랜덤함수를 쓸 것이다. 그렇기 때문에 암호학적으로 안전한 SecureRandom 클래스를 쓴다. 보통 많이 접하는 util.Random 클래스는 암호학적으로 안전하지 않기 때문이다. (상세 이유는 아래 더보기를 눌러 링크된 포스팅을 참고 바란다.)

 

그리고 우리가 알아보았던 해시함수를 쓰기위해 가장 쉽게 사용할 수 있는 MessageDigest 클래스를 import 해준다. 자세한 사용은 아래 코드를 보면서 메소드별 설명과 함께 같이 이해해보자. (보통은 더 좋은 외부라이브러리를 많이 사용한다. 필자의 경우도 여러 라이브러리를 갖고있다. 아마 외부 라이브러리 중에서는 가장 접근성 좋으면서 나름 좋은평을 받고있는 bouncycastle 이 가장 무난하지 않나 싶다.)

 

 
import java.security.MessageDigest;
 
import java.security.SecureRandom;
 
 
 
public class User {
 
 
 
private static final int SALT_SIZE = 16;
 
private static DB db = new DB();
 
 
 
 
 
// 새로운 계정 만들기
 
public void set_User(String ID, byte[] Password) throws Exception {
 
String SALT = getSALT();
 
db.set_USER(ID, Hashing(Password, SALT), SALT);
 
}
 
 
 
 
 
// 유저 정보와 대조한 뒤 로그인 하기
 
public void get_User(String ID, byte[] password) throws Exception {
 
String temp_salt = db.get_SALT(ID); // 해당 ID의 SALT 값을 찾는다
 
 
 
String temp_pass = Hashing(password, temp_salt); // 얻어온 Salt 와 password 를 조합해본다.
 
 
 
if(db.check(ID,temp_pass)) { // db 에 저장된 아이디와 비밀번호를 대조한다
 
System.out.println("로그인 성공");
 
}
 
else {
 
System.out.println("로그인 실패");
 
}
 
 
 
}
 
 
 
 
 
// 비밀번호 해싱
 
private String Hashing(byte[] password, String Salt) throws Exception {
 
 
 
MessageDigest md = MessageDigest.getInstance("SHA-256"); // SHA-256 해시함수를 사용
 
 
 
// key-stretching
 
for(int i = 0; i < 10000; i++) {
 
String temp = Byte_to_String(password) + Salt; // 패스워드와 Salt 를 합쳐 새로운 문자열 생성
 
md.update(temp.getBytes()); // temp 의 문자열을 해싱하여 md 에 저장해둔다
 
password = md.digest(); // md 객체의 다이제스트를 얻어 password 를 갱신한다
 
}
 
 
 
return Byte_to_String(password);
 
}
 
 
 
 
 
// SALT 값 생성
 
private String getSALT() throws Exception {
 
SecureRandom rnd = new SecureRandom();
 
byte[] temp = new byte[SALT_SIZE];
 
rnd.nextBytes(temp);
 
 
 
return Byte_to_String(temp);
 
 
 
}
 
 
 
 
 
// 바이트 값을 16진수로 변경해준다
 
private String Byte_to_String(byte[] temp) {
 
StringBuilder sb = new StringBuilder();
 
for(byte a : temp) {
 
sb.append(String.format("%02x", a));
 
}
 
return sb.toString();
 
}
 
 
 
public void get_DB() {
 
System.out.println("\n================DB출력================\n");
 
System.out.println(db);
 
}
 
}

 

 

 

[set_User]

사용자가 새로운 계정을 만들 때 쓰이는 메소드다. ID 와 패스워드를 입력받으면 getSALT() 메소드를 통해 해당 아이디에 쓰일 고유한 SALT 를 받는다. 그리고 Hasing메소드에 입력받은 패스워드와 SALT 를 보내 패스워드를 암호화(해싱)시킨다. 이렇게 ID 와 새로 얻은 새로운 다이제스트, 해당 아이디의 고유한 SALT 를 DB 클래스에 있던 set_USER() 메소드로 보낸다.

 

 

[get_User]

사용자가 로그인을 하고자 아이디와 패스워드를 입력한다. 이렇게 입력받으면 먼저 해당 ID 의 고유 SALT 값을 찾아 temp_salt 변수에 임시로 담아둔다. 그리고 사용자가 입력한 패스워드와 temp_salt 를 Hashing 메소드로 보내 다이제스트를 얻어온다. 이렇게 얻어진 다이제스트와 사용자가 입력한 ID 를 DB 클래스의 check 메소드로 보내서 DB 에 있는 UserData 의 ID와 다이제스트와 일치하는지를 알아본다. 만약 두 데이터 모두 일치한다면 true 로 로그인에 성공했다는 의미고, 하나라도 일치하지 않을경우 false 로 로그인에 실패했다는 의미다.

 

 

[Hashing]

이번 포스팅에서 가장 중점적으로 다룬 부분이다. 입력받은 비밀번호와 Salt 를 합쳐서 여러번의 해싱을 거쳐 최종 다이제스트를 보내는 역할을 한다. 좀 더 구체적으로 말하면 앞서 import 했던 MessageDigest 클래스를 사용한다. SHA-256 알고리즘을 사용하기 위해 위와같이 선언하면 된다. ( MessageDigest md = MessageDigest.getInstance("SHA-256"); )

new 생성자를 따로 쓸 필요 없이 위와같이 하면 자동으로 새로 생성이 되므로 참고하시면 된다. 그 외에도, MD5, SHA-1 도 지원한다.

 

그리고 이제 패스워드와 솔트를 합쳐서 해싱하고, 얻어진 다이제스트를 다시 솔트와 합쳐 해싱하고.. 이렇게 반복을 10000번 한다. 참고로 md.update() 가 바로 입력한 문자열을 해싱하는 함수다. 이 때 문자열은 바이트배열이어야 한다. 그렇기 때문에 해당 문자열을 getBytes() 메소드를 통해 바이트배열로 반환시켜 넣어야 하며, 다이제스트를 반환하는 md.digest() 또한 기본적으로 바이트배열로 반환하기 때문에 이후에 설명 할 Byte_to_String 메소드를 통해 다시 문자열로 변환해주는 메소드를 필요로 한다.

해시함수 단점 보완하기에서 키 스트레칭이 바로 이 과정에 해당된다. 즉, 다이제스트를 복호화하기 어렵게 만들면서도 의도적으로 시간을 지연시키는 방법인 것이다. 이렇게 반복한 뒤 얻어진 다이제스트를 문자열로 변환시켜 반환해준다.

 

 

[getSALT]

임의의 문자열을 생성해주는 솔트 생성 역할을 한다. 일반적인 Random 클래스와 사용방법은 크게 다르지 않다. 임의의 바이트 배열을 생성한 뒤, nextBytes() 에 해당 배열을 넣어, 바이트 배열이 임의의 값들로 채워지도록 하는 것이다. 그렇게 채워진 배열이 바로 솔트가 되는 것이고, 해당 배열을 반환해준다.

 

 

[Byte_to_String]

말 그대로 byte 배열을 String 으로 변환해주는 역할을 한다.

 

 

[get_DB]

이후에 main 에서 관리자가 DB 에 있는 데이터들을 출력하기 위해 만든 메소드다. 앞서 DB 클래스에서 toString() 을 오버라이드 하면서 객체 출력을 재정의 했기 때문에 db를 출력해주도록 하면 끝난다.

 

 

 

 

 

3. Main

 

사용자가 실질적으로 사용하게 되는 메인화면의 역할을 한다.

크게 설명할 점은 없으니 코드를 보면서 이해해보도록 하자.

 

 
import java.util.Scanner;
 
 
 
public class Main {
 
 
 
static Scanner in = new Scanner(System.in);
 
static User user = new User();
 
 
 
public static void main(String[] args) throws Exception {
 
 
 
 
 
 
 
while(true) {
 
System.out.println("1 : 회원가입 \t 2 : 로그인 \t 3 : 종료 ");
 
System.out.print(">>>>>> ");
 
 
 
String n = in.nextLine();
 
 
 
if(n.equals("1")) {
 
make_user();
 
}
 
else if(n.equals("2")) {
 
longin();
 
}
 
else if(n.equals("3")) {
 
break;
 
}
 
else if(n.equals("st-lab")) {
 
System.out.println("관리자 접근");
 
user.get_DB();
 
}
 
else {
 
System.out.println("잘못 입력");
 
}
 
System.out.println();
 
}
 
 
 
 
 
}
 
 
 
static void make_user() throws Exception {
 
System.out.print("아이디 입력\n>>>>>> ");
 
String id = in.nextLine();
 
System.out.print("비밀번호 입력\n>>>>>> ");
 
String pass = in.nextLine();
 
user.set_User(id, pass.getBytes());
 
}
 
 
 
static void longin() throws Exception {
 
System.out.print("아이디 입력\n>>>>>> ");
 
String id = in.nextLine();
 
System.out.print("비밀번호 입력\n>>>>>> ");
 
String pass = in.nextLine();
 
 
 
user.get_User(id, pass.getBytes());
 
 
 
}
 
 
 
}

 

 

while 문으로 무한루프로 돌린뒤, 3을 입력 받기 전까진 계속 진행하도록 만들었다.

그리고 보면 알 수 있듯이 1, 2 은 각각 회원가입과 로그인 기능이며, st-lab 을 입력받으면 관리자권한으로 유저들의 데이터를 모두 출력하도록 비밀(?)스러운 기능을 넣었다.

 

한 번 그럼 실행하여 결과를

 

보도록 하자.

 

 

 

 

 

 

매우 잘 실행된다!

보다시피 필자가 유저들의 정보를 훔쳐본다거나 누군가 해당 정보들을 탈취했다 하더라도 DB 에 저장된 패스워드는 이미 해싱되어 있기 때문에 브루트포스를 사용하지 않는이상 원래 비밀번호를 알아내기 힘들다.

 

 

 

혹여 드래그 하기 귀찮으신 분들을 위해 첨부파일로도 올려두겠다.

참고로 필자는 JDK13을 쓰는데 프로젝트폴더 째로 export 하면 가끔 path 가 잘 안되는 경우가 있으므로 java 파일만 올려두겠다.

그리고 주석에서도 썼지만 필자는 맥OS에서 작성해서 윈도우에서 바로 이클립스에 넣으면 한글의 경우 깨질 것이다.(대부분의 윈도우 사용자는 MS949 를 사용하므로..)

 

그렇기 때문에 해당 파일을 넣었으면, 다음과 같이 설정하시길 바란다.

Package explorer -> DB(User, Main).java 우클릭 -> properties -> resource -> text file encoding -> MS949 를 UTF-8 로 변경 

이와같이 하면 한글이 깨지지 않고 잘 출력될 것이다.

해당 클래스에만 적용되는 것이므로 다른 파일들에 대한 영향은 없다. 즉, DB.java, User.java, Main.java 파일만 위와같이 설정해주도록 하자.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

  • 정리

 

 

 

 

사용자의 패스워드를 안전하게 해싱하고 저장하는 방법을 알아보았다. 물론 위 방법이 가장 안전한 방법은 아니다. 또한 단방향으로만 알려주었기 때문에 양방향에 대해서는 따로 언급하지 못했으나, 각 상황별에 맞게 사용해야한다. 만약 암호화와 복호화까지 설명한다면 포스팅이 너무 길어질 것 같아서.. (적어도 패스워드는 단방향으로 암호화를 하여 저장해야한다는 법 때문에라도 의미가 있다고 생각한다.)

그래도 기본적인 개인정보를 해싱 하는 방법에 대해 알아보고 원리를 이해할 수 있다는 점에 중점을 두었으니 많은 분들이 도움 되었으면 한다. 여러분들이 개발자가 된다면 아마 한 번쯤은 접하게 되는것이 정보를 암호화 하는 것이다. 당연히 인터넷의 보급률이 높아질수록, 더욱이 보안이 뛰어날 수록 공격자들도 더욱 발전된 해킹방법을 쓸 것이다.

 

또한 위 방법은 어디까지나 구조를 표현한 것일 뿐 실제로 데이터 해싱을 저렇게 하지는 않는다.

 

특히 중요한 점은 로그인 과정의 경우 웹이나 클라이언트 어플리케이션에서 서버와의 통신을 할 경우 반드시 서버로 보내기 전에 암호화를 해야한다. 그렇지 않으면 악의적 사용자가 중간에 데이터를 가로채게 될 경우 패스워드를 그대로 얻을 수 있기 때문이다.

사실 여기서는 해싱에 대한 기본적인 원리만 다뤘지만, 실제로는 SSL, HTTPS 등을 복합적으로 사용하여 서버로 보내기 전에 양방향, 단방향 등으로 한 번 평문을 해싱 혹은 암호화 한 뒤에 보내서 서버 단에서 내부적으로 공개되지 않은 해싱 알고리즘으로 다시 한 번 돌리게 된다. 그러나 이를 모두 다루려면 글이 매우 길어지기도 하고, 이 번에는 해싱에 대해 알아보고자 했기에 여기에 집중해서 어떻게 쓰이는지를 보는 차원에서 쓴 것이니, 위의 구현 방식을 신뢰해서는 안된다는 점 미리 말씀 드린다.

 

 

 

그런김에 단방향 해시 함수로 많이 쓰이는 대표적인 몇 가지 알고리즘을 언급하겠다.

-PBKDF2

-Bcrypt

-Scrypt

-Argon2

(필요하다면 추후 포스팅을 해보도록 하겠다.)

 

마무리 해보자. 암호학은 우리 생활에 필수요소고, 실제 단순 서비스 가입뿐만 아니라, 블록체인, 사용자인증, 정품인증 등 눈에 직접 보이지는 않지만 어디에나 존재하는 것이 바로 암호학이라고 해도 과언이 아닐 것이다.

 

위 예제는 물론 프로그램을 종료하면 UserData 는 더이상 남아있지 않게된다. 정말 서버와 비슷하게 구현을 해보려면 가장 간단한 방법은 txt 파일을 만들어서 프로그램을 실행하면 txt 파일을 실시간으로 읽어오고, 실시간으로 업데이트 하는 방법을 쓰면 된다. (DB를 다룰줄 안다면 오히려 더 쉽게 구현이 가능 할 것이다.)

 

많은 도움이 되었길 바라며 포스팅을 마친다.

 

'자바' 카테고리의 다른 글

[node.js] 설치하기  (0) 2023.03.23
[입력] Scanner클래스와 입력  (0) 2023.03.23
[자료구조] Array와 List  (0) 2023.03.23
[예외] 예외 던지기  (0) 2023.03.23
[예외] try catch  (0) 2023.03.22

관련글 더보기

댓글 영역