비밀번호를 단방향 암호화하는 이유
사이트를 만드는 누구나 당신의 비밀번호를 볼 수 있다면
<회원가입의 역습: 당신이 직접 준 비밀번호>, 2024
생각해 봅시다. 제가 만든 사이트에 여러분이 접속해서 회원가입을 합니다.
여러분은 아마 평소에 자주 사용하는 계정과 비밀번호가 있을 거예요.
"그리고 그걸 저에게 주겠죠."
제가 만든 유익하거나 댕쩌는 재미를 주는 사이트를 이용하려고 회원가입을 할 거니까요.
당신의 메일을 도용하기
저는 이제 당신의 메일 중 하나를 찾아 봅니다. 당신은 한국인이기 때문에 어쩌면 네이버 메일을 주로 사용하고 있을 수 있고, 그 메일로 가입한 사이트가 200곳이나 될지도 모릅니다.
당신의 메일을 통한 인증
그러면 이제 그 200개 사이트에서 '비밀번호 변경'을 눌러서 '메일로 인증하기'를 선택한다면, 당신 행세를 하며 비밀번호를 바꾸고, 당신의 자격으로 그 사이트를 이용할 수 있겠네요. 그리고 SNS에 접속해서 당신인 척을 하며 당신의 주변 사람들을 속일 수도 있습니다.
이처럼 아무 사이트에 회원가입만 해도 비밀번호를 그대로 노출시킬 수 있고, 직원 중 누군가가 악의적인 생각을 품으면 사용자들의 비밀번호를 알 수 있을지도 모릅니다.
사용자 비밀번호 도용을 막기 위한 습관도 있습니다.
위와 같은 시도를 100% 막을 수는 없습니다. 따라서 우리는 사이트의 운영자가 내 비밀번호를 알아내도 다른 사이트에서 사용할 수 없도록 사이트마다 다른 비밀번호를 사용할 수 있습니다.
비밀번호 관리자를 사용해서 사이트마다 다른 암호를 쉽게 관리하거나, 사이트 도메인으로부터 추가적인 암호문을 만들어 비밀번호의 일부에 덧붙이는 나만의 패턴을 추가하는 것 등이 비밀번호 관리에 도움을 줍니다. 이러한 방안 중 비밀번호 관리자는 매우 편리하고 강력한 수단이지만, 신뢰할 수 있는 서비스를 사용해야 합니다.
여기까지는 일반 사용자의 노력입니다. 더 중요한 것은 시스템적으로 많은 사용자가 안심하고 사용할 수 있어야 한다는 거겠죠.
여러 나라가 안전한 서비스를 보장하기 위해 노력합니다.
우리나라를 포함해서 많은 국가들에서 온라인 서비스가 안전하게 운영되도록 노력하고 있습니다. 보안은 많은 나라가 보장하려고 하는 중요한 요소겠죠. 서비스가 클수록 보안 심사를 통해 사용자들이 안심하고 사용할 수 있는 서비스인지 확인도 하고, 필요하다면 법률을 만들거나 노동자들의 직업 윤리와 더불어서 회사의 서비스가 고의나 과실에 의한 보안 위배를 하지 않도록 이끌고 있습니다.
역시 보안은 물리적·기술적 조치보다 '관리적' 조치
개인정보의 유출은 70% 이상이 내부자에 의해 발생한다는 설명을 본 적이 있습니다. 바로 납득했어요. 대부분의 보안은 물리적·기술적인 것을 마련한 상태로 유지할 수 있지만, 정책은 미리 마련해도 운영하는 과정에서 '인재(人災)'가 생길 수 있고, 관리적으로 미비하면 물리적 기술적 조치도 많은 부분 생략될 수 있거든요.
우선 각 기업의 보안 조치는 크게 세 가지로 나누어서 검토합니다.
물리적 조치 (물리적 자산에 대한 접근 제어 및 유지)
보안 시스템이 안정적으로 작동하며, 외부인의 출입 등으로부터 안전하게 보호되어야 합니다.
(출입 통제, CCTV, 비상전력, 방화방재 시스템 등)기술적 조치 (직접적인 정보 보호)
하드웨어와 소프트웨어를 모두 포함하는 개념입니다. 방화벽 설정, 네트워크 모니터링과 적절한 대응, 암호화와 인증, 안티바이러스, 망 분리 등이 기술적 조치에 해당합니다.
관리적 조치 (인력과 정책)
인력에 대한 역할 및 책임 분배, 인력과 작업에 대한 감사 및 모니터링, 정책 수립과 문서화, 보안 교육, 접근 권한 관리, 재해 복구 계획 중 보안사고 관련 시나리오 등을 포함합니다.
비밀번호의 암호화는 이중에 기술적 조치에 해당하지만, 그것을 시행할 것인지 여부는 관리적 조치에서 잘 뒷받침되어야 합니다.
그래서 20대 30대 40대 개발자가 무조건 해야 하는 '이것'
"비밀번호는 반드시 단방향 암호화하여 저장하여야 합니다."
비밀번호 암호화는 '기술적 조치'에 해당하지만, 만약 의무가 아니었다면 어쩌면 생략하는 기업이 많았을 수도 있습니다. 그래서 우리나라를 포함한 많은 나라에서 '비밀번호의 암호화'는 필수로 하고 있죠.
"그리고 비밀번호 암호화는 원본 평문을 알 수 없도록 해야 합니다."
이것을 위해 우리는 '단방향 암호화'라는 것을 하고, 단방향 암호화는 '암호화(평문→암호문)'는 있지만, '복호화(암호문→평문)'는 없다는 것이 특징입니다. 따라서 사용자가 비밀번호를 입력하였을 때, 곧바로 단방향 암호화하여 저장하면 사용자의 비밀번호는 원본을 알 수 없는 상태로 저장되기 때문에 악용되지 않죠. 아, 이것을 의무화하였다니, 이래서 지금까지 우리가 안전한 거였습니다.
물론 개인이 운영하는 사이트나 이상한 사이트는 함부로 가입하면 안 되겠죠. 회사 단위로 운영되고 있는 서비스는 보통 직원들의 보는 눈이 있기 때문에, 중요한 조치는 포함되어 있을 것입니다. (실제 존재하는 회사여야 하고, 최소한의 규모나 사업 자격 심사 등을 통과한 상태여야 완전히 믿을 수 있습니다.)
해싱(Hashing)과 단방향 암호화
일반적인 시스템에서 사용하는 단방향 암호화는 '해싱(hashing)'이라는 기술을 핵심 동작으로 합니다.
해싱은 값을 가는 행위라고 생각할 수 있습니다. 해시라는 건 무언가를 갈아서 뭉치는 것을 말하거든요. 해시 함수는 입력받은 값을 갈아서 고정된 길이로 결과를 반환합니다. 그리고 '갈아 낸다'는 표현 방식을 택한 것처럼 해시 함수를 통해 얻어낸 결괏값인 다이제스트(digest)는 다시 원본으로 역변환할 수 없는 산물입니다. 이것을 비밀번호 암호화에 사용하는 거죠.
해싱을 하면 원래 문자를 알 수 없습니다.
대신 같은 내용을 (동일한 조건으로) 해싱하면 항상 같은 결과를 준다는 특징이 있습니다.
Q. 해싱을 하면 원본을 알 수 없다는 건데, 비밀번호를 나중에 어떻게 대조해요?
복호화를 할 수 없다는 건 결국, 사용자가 로그인을 할 때 비밀번호를 입력해도 그것이 맞는지 비교할 수 없다는 말처럼 들릴 수도 있습니다. 몇몇 분들은 예리하게 그 포인트를 찾아내 물어 보더군요.
그래도 잘 생각해 봅시다. 우리는 사용자 비밀번호 원문을 알 필요가 없어요. 왜냐하면 서로 동일한지 비교하는 것은 굳이 원문이 아니더라도 해싱이 된 결과끼리 비교해서 확인하면 되거든요. 해시 함수는 같은 입력값에 대해 항상 같은 결과가 나온다는 것을 보장해 주기 때문에, 양쪽 데이터를 모두 해싱한 상태에서 서로 동일한지 비교하면 됩니다.
단방향 암호화는 보안의 3 요소 중 기밀성, 무결성을 직접적으로 제공하고, 가용성은 비교적 간접적으로 제공하는 것으로 평가합니다. 전체 보안 시스템에서 해싱과 단방향 암호화는 주로 기밀성을 제공하는 데 사용하고, 필요하다면 무결성을 위해서도 활용합니다(전송·보존된 중요 데이터의 위·변조 방지). 시스템 가용성(주로 시스템의 안정적 이용과 관련)을 위해 도입하는 직접적인 기술은 아닙니다.
현대적인 단방향 암호화 구현의 핵심 기법 3 요소
현대적인 비밀번호 단방향 암호화는 다음 요소들을 포함합니다.
암호학적 해시 함수(cryptographic hash function)
솔트(salt): 특히 가변 솔트(dynamic salting).
키 스트레칭(key stretching)
암호학적 해시 함수(Cryptographic Hash Function)
암호학적으로 안전한 해시 함수는 다음과 같은 특징을 충족합니다. 단어만 어렵고, 내용은 쉽습니다.
제1 역상 저항성: 해싱된 결과만 보고 입력값을 유추하는 것이 난해해야 합니다.
제2 역상 저항성: 입력값 1과 그 해싱된 결과만 보고, 똑같은 해싱 결과를 만들 수 있는 입력값 2를 알아내는 것이 난해해야 합니다.
충돌 저항성: 동일한 해싱 결과를 만들 수 있는 다른 입력값이 거의 없어야 합니다. 즉, 해시 충돌이 거의 없어야 합니다.
충돌 저항성(Collision Resistance)
해시 충돌이 적어야 합니다. 서로 다른 두 입력값이 같은 해싱 결과로 매핑되는 일이 거의 없어야 합니다.
해시 충돌: 서로 다른 입력값끼리 동일한 해싱 결과를 갖는 현상입니다. 해싱이 일대일 매핑이 아니기 때문에 나타날 수 있습니다.
역상 저항성(Pre-Image Resistance)
해싱 결과만 보고 입력값을 유추하는 것을 어렵게 합니다.
제2 역상 저항성(Second Pre-Image Resistance)
"갑각류 없이 새우와 똑같은 맛과 식감, 동일한 영양 성분까지 만들어 봐."
"좋은 아이디어네요, 노벨상 받을 지식만 있다면요."
입력값 1과 그 해싱된 결과만 보고, 똑같은 해싱 결과를 만들 수 있는 입력값 2를 알아내는 것이 난해해야 합니다.
역상 저항성(제1 역상 저항성)이 '해싱 결과만 보고' 입력값을 유추하기 어렵다는 것이었다면, 제2 역상 저항성은 '입력값과 해싱 결과'를 아는 상태에서, 같은 해싱 결과가 나올 수 있는 다른 입력값을 유추하기 어렵다는 것입니다. 해시 충돌이 생기는 조합을 일부러 찾는 것은 어렵다는 거예요. 이로써 특정 결과를 만드는 데 필요한 패턴을 유추하기 어렵기 때문에 악용을 방지할 수 있거든요.
인풋을 조금만 바꾸어도 해싱 결과가 크게 바뀌는 '눈사태 효과'라는 것이 있습니다. 눈사태 효과 덕분에 인풋-아웃풋의 규칙성을 유추하기 더 어렵기 때문에, 제2 역상 저항성은 눈사태 효과의 도움을 받습니다.
레인보우 테이블
"해시 함수는 역변환은 안 되지만, 같은 입력에 대해 항상 같은 결과를 준다는 특징이 있었죠."
해시 함수는 이런 특징을 이용해서 정답표를 만들 수 있습니다. 우리는 그것을 '레인보우 테이블'이라고 부르죠.
즉, 레인보우 테이블은 해시 함수에서 나올 수 있는 값들을 미리 계산해 표로 만든 것이고, 원하는 결과를 얻을 수 있는 입력값을 찾는 데에 사용합니다. 레인보우 테이블을 방지하기 위해서 암호학적 해시 함수의 주요 특성인 역상저항성 등이 왜 필요한지 이해할 수 있죠.
솔트 첨가
"레인보우 테이블은 같은 입력값이 같은 결과를 보장하기 때문에 생긴다고 했습니다."
그래서 회사들은 '사용자 입력에만 의존하지 않는' 입력값을 만들면 된다는 것을 잘 활용하고 있죠. 우선 사용자가 넣은 입력값에 '솔팅(salting)'이라는 처리를 합니다. 영어로 써서 대단해 보이지만, 해석하면 '소금을 친다'라는 뜻으로 기본적으로 간단한 작업입니다. 사용자 입력값을 바꿔서 쓰는 거죠.
변환함수(사용자 입력값, 솔트) = 해커가 모르는 입력값
→ 해시 함수에 사용
간단한 솔팅
제1, 제2 역상 저항성을 만드는 '눈사태 효과' 덕분에, 변환 함수는 아주 간단하게도 가능합니다. 어차피 내용이 조금만 바뀌어도 결과가 완전히 바뀌기 때문에 복잡하게 변환할 필요가 없겠죠. 단순히 문자열을 이어 붙이는 등의 간단한 전처리로도 충분합니다.
사용자 입력값 + 솔트 = 해커가 모르는 입력값
→ 해시 함수에 사용
고유한 솔트
솔팅은 단순한 문자열 결합일 수 있지만, 중요한 것은 이 변환이 예측 불가능하고 고유한 결과를 만들어 내는 것입니다. 이렇게 함으로써, '사용자 입력값'이 같아도 '다른 솔트'를 합쳐서 해시 함수에 사용하기 때문에, 해시 값으로는 사용자 입력값을 유추할 수 없게 만들어 레인보우 테이블의 생성을 막죠.
고유한 솔트의 대표적인 예로는 '가변 솔트'가 있습니다.
가변 솔트(Dynamic Salting)
가변 솔트는 새로운 비밀번호를 암호화할 때마다 새롭게 생성되는 솔트입니다. 주로 랜덤하게 생성하죠.
가변 솔트를 적용하면 같은 내용을 암호화하여도 서로 다른 암호문을 얻을 수 있기 때문에 더욱 예측하기 어려운 암호화를 할 수 있습니다. 회사의 내부자가 DB에 접근해도 원래 내용을 유추할 수 없죠. (로그인 시에는 암호가 서로 일치하는지 비교하기 위해 암호화에 사용한 솔트를 그대로 다시 사용합니다.)
잘 생성된 가변 솔트를 단방향 암호화에 다룰 때, 작업에서 특징적인 부분은 다음과 같습니다.
데이터베이스에는 암호화된 비밀번호와 가변 솔트를 함께 저장합니다.
이때는 보통 하나의 문자열로 저장합니다.
저장 양식의 예시는 다음과 같고, 양식은 함수와 요구사항마다 다릅니다.
{암호화함수이름}$버전_등_함수별_필요정보들$초기_솔트와_해싱된_비밀번호
로그인 등에서 비밀번호를 확인할 때는 데이터베이스에서 솔트를 읽어 와서, 데이터베이스에 저장된 비밀번호와 동일한 함수와 솔트를 사용해 암호화하고, 둘 다 암호화된 상태에서 비교합니다.
암호학적으로 안전한 랜덤 함수를 사용해야 합니다.
고유한 솔트는 '예측이 불가능해야' 한다고 했고, 가변 솔트는 랜덤하게 만드는 것이 일반적이라고 했죠. 하지만 컴퓨터는 100% 랜덤은 만들 수 없다 보니까, 모든 건 산출돼서 나오는 연산 결과입니다. 시드와 함수에 따라 특정 값 패턴으로 나와요. 우린 그걸 순서대로 사용하는 거죠.
하지만 그러면 다른 사람이 유추할 수 있는 정보가 될 수도 있거든요. 그래서 이렇게 유출되어선 안 되는 랜덤은 유추하기 어려운, 즉 암호학적으로 안전한 랜덤 함수를 사용해야 합니다.
현대적인 비밀번호 암호화 함수들은, 우리가 따로 솔트를 생성하지 않아도 암호학적으로 안전한 솔트를 내부적으로 생성하는 것이 보편적입니다.
안전하고 랜덤한 자동 솔트 생성 (가변 솔트의 자동 적용)
그 함수의 표준 스펙에 명시되어 있을 수 있습니다.
예: BCrypt, SCrypt, ARGON2
그 함수의 표준 스펙에 없지만, 주요 라이브러리 구현에서 솔트의 자동 생성을 보조할 수 있습니다.
예: 스프링 시큐리티 PBKDF2 인코더
반복 해싱(Iterative Hashing)
Fast Hashing, Slow Password Encryption
"해싱은 빨라, 빠르면 기차.
기차는 길어, 길면 비밀번호 암호화."코드몽키 엉덩이는(힙은) 빨개(Code Monkey's Heap is Red), <디지털 시대의 동요>
해싱은 원래 빠른 동작을 제공해야 한다는 원칙이 있습니다.
해싱은 단방향 암호화를 제공하기 위해서만 사용하는 것이 아니고, 동시에 많은 처리나 빠른 처리를 하는 시스템에서도 해싱을 중요하게 사용할 때가 있기 때문입니다.
단방향 암호화에서는 굉장히 많은 해싱을 반복하여 일부러 느린 동작을 유도합니다.
빠른 해싱을 베이스로 하면서도, 적어도 수백 번, 통상 수천 번 이상 반복적인 해싱을 하는 것이 비밀번호 암호화 함수들의 핵심 기술 중 하나입니다. 이때는 매 회차에 영향을 주는 각종 기술이 첨가되기도 하죠.
다음 그림은 그 예시로, 각 해싱 회차에서 생성되는 정보로 내부 상태를 업데이트하고 다음 회차 해싱에 내부 상태가 영향을 주는 과정을 간략히 표현합니다. 이 내부 상태는 보통 암호화 함수 종료 시 소멸하고, 필요한 정보만 반환하여 보존합니다. 처음부터 똑같이 암호화하면 같은 내부 상태를 재현하며 동일하게 암호화할 수 있고, 도중에 한 번이라도 암호화함수를 종료했다가 다시 그 결과를 재사용하여 암호화하면 새로운 내부 상태를 사용하여 다른 결과가 나옵니다. 이는 반복 해싱 횟수의 합산이 같더라도, 각 암호화 함수의 시행이 독립적이어서, 만약 반복 해싱을 1024번씩 적용하여 두 차례 암호화한 것과 2048번의 반복 해싱으로 한 차례 암호화한 것을 비교하면 서로 다른 결과를 확인할 수 있다는 것을 의미하죠. (이런 정보는 추후, 운영 중인 시스템의 암호화 함수를 개선해야 할 때 중요한 고려사항이 됩니다.)
반복 해싱을 적용함으로써 레인보우 테이블, 브루트포스 등의 공격을 막을 수 있습니다.
레인보우 테이블 생성은 사실상 시도하는 사람이 없게 됩니다.
성능을 요구하는 작업이 되기 때문에, 랜덤 대입 공격의 시도 횟수를 감소시켜 사용자를 보호합니다.
레인보우 테이블을 시도하지 않는 이유
사용자 입력에 대한 해시 값을 알 수 있어야 레인보우 테이블을 사용할 수 있기 때문에 요즘은 레인보우 테이블을 거의 사용할 수 없는 시대입니다.
사용자의 해시 값을 알게 되더라도 사용자마다 솔트가 다르고, 반복 해싱으로 레인보우 테이블 생성 비용이 상당히 높습니다. 사용자 한 명을 대상으로는 동일한 해시 값을 얻을 수 있는지만 확인하면 되기 때문에 레인보우 테이블을 만들 필요 없이 랜덤으로 대입해 보는 공격이 효율적입니다.
특히 암호화 함수의 종류와 인자 값(예: BCrypt의 버전과 cost factor) 등을 시스템만 인식할 수 있거나 관리자가 특정 상황에만 열어 볼 수 있게 하고, 인증 DB에 대체된 정보로 저장한다면, 반복 인자 등을 알 수 없기 때문에 레인보우 테이블 생성 시 보존해야 할 정보가 기하급수적으로 증가합니다.
반복 해싱이 실질적으로 더 유효한 도움을 준다고 평가하는 것은 랜덤 대입 공격(브루트 포스 공격류)을 훨씬 줄일 수 있다는 것입니다.
🔍 브루트포스 공격(무차별 대입 공격)
레인보우 테이블은 일찌감치 옛날 이야기가 되었습니다. 이제 남은 것은 암호를 찍어 보는 겁니다.
브루트포스는 공격 환경에 따라 두 가지 방식으로 나눌 수 있습니다.
서비스 플랫폼에 직접 대입
사용자의 비밀번호 해시 값을 모를 때 사용합니다.
플랫폼에 무작위로 비밀번호를 대입할 수 있습니다.
본인의 컴퓨터에 환경을 구축하여 제한 없이 대입
사용자의 비밀번호 해시 값이 노출된 경우 사용합니다.
동일한 해시 함수, 인자, 솔트를 사용하여 무작위로 사용자 비밀번호의 해시 값을 생성하는 방식입니다.
요즘 많은 사이트가 비밀번호를 '연속으로' 틀리면 계정을 보호 상태로 전환하는 조치를 하기 때문에, 계정당 연속 시도 횟수를 줄이고 장기적으로 재시도를 하는 식으로 공격할 수도 있습니다. 예를 들어, 사이트 정책상 연속 시도 횟수를 24시간마다 초기화한다면 공격자의 연속 시도 횟수를 초기화받으며 매일 재시도할 수 있고, 연속 시도 횟수를 초기화하지 않는다면 비활성 계정들에 대한 공격을 줄일 수 있습니다.
주기적으로 초기화하지 않고 로그인이 되는 시점에만 초기화하는 방식이면, 로그인 연속 시도 횟수를 초기화받는 계정은 주로 활성 계정일 것이며, 계정이 탈취되더라도 비교적 빠른 시일에 확인이 가능할 것입니다.
단, 단기간에 큰 피해가 생길 수 있는 금융, 메일 등 중요한 서비스는 추가적인 탐지 및 방지로 각별히 주의합니다.
🔍 딕셔너리 공격 (사전 공격)
찍어도 남들이 많이 쓰는 암호로 찍어 보겠다는 공격입니다. 사람들이 자주 사용하는 비밀번호 목록을 참고하여 다른 사용자의 계정으로 로그인을 시도합니다. 이 공격은 가끔 소셜 엔지니어링과 결합하여 사용자 개인정보를 바탕으로 시도될 수도 있습니다. (생일, 전화번호, 가족 생일, 가족 전화번호, 실명, 기념일, 별명, 출신지 등 주로 SNS에 노출되어 있는 정보)
느린 로그인을 지향하기
로그인은 의도적인 지연을 허용합니다. 표준화된 기준이 없지만, 서버에서만 1초 정도를 소요하는 것을 목표로 할 수 있습니다.
안정성 측면
암호화 연산에 상당한 비효율을 발생시켜 입력 값을 알아내기 어렵게 만듭니다.
사용자 비밀번호의 해시 값 노출은 있어서는 안 될 일이지만, 사고로 발생하거나 내부자에 의해 접근될 수 있습니다.
이 해시 값을 만들기 위한 입력값을 찾는 것은 실제 서비스 대신 공격자 본인의 컴퓨터에서 암호화 함수를 실행하여 수행될 수 있습니다. 위에서 언급한 딕셔너리 공격이나 브루트포스 등이 공격자 본인 컴퓨터에서 제한 없이 수행되는 상황을 말합니다.
일부 암호화 함수의 '하드웨어 저항성'이라는 특징이 특정 소요 시간 이상에서 효과적일 수도 있으며, 특히 500밀리초나 1초 이상에서 효과적일 수 있다는 실측 보고들이 있습니다.
(* 관련 실험의 공식적인 리포트나 논문이 아니라, 사람들의 실측 보고를 바탕으로 합니다.)
사용자 경험 측면
1초를 조금 넘는 정도의 로그인 시간은 사용자가 불편함을 느끼지 않는다고 가정하고 보안을 우선시하는 것입니다.
많은 사용자가 로그인을 기다리는 것에 적응했으며, 1초를 조금 넘는 로그인 시간은 크게 불편하지 않은 것으로 가정합니다.
(* 관련해서 객관적인 통계나 사용자 경험에 따른 피드백 리서치가 발견되지 않았습니다.)예를 들어 구글은 로그인 응답에 3초 이상을 소요하기도 하는데, 사용자가 이를 체감하지 못할 때도 있습니다.
로그인은 서비스 이용 내내 빈번하게 발생하는 작업이 아닙니다. 낮은 빈도로 발생하고, 어떤 작업의 선행 작업 정도로 생각되기 때문에, 사용자가 조금은 기다릴 수 있다고 가정합니다.
기타
비밀번호 최대 길이 제한
길수록 좋은 비밀번호
비밀번호는 길수록 좋습니다.
"나는다람쥐와도토리묵을묵찌빠스타"처럼 특별히 맥락은 없는데 암기할 만한 문장이나 단어 조합을 사용하면 다른 사람이 쉽게 유추할 수 없고, 자릿수가 노출되어도 매우 길기 때문에 경우의 수가 많아 계정 보호에 도움이 됩니다.
초보자분들의 프로젝트에서 데이터베이스 설계를 들여다 보면, 가끔 비밀번호 길이 제한을 두려고 하고, 그걸 또 데이터베이스에 반영하는 것을 발견합니다. 또 일부 공기업과 공공기관 사이트가 사용자 계정의 비밀번호를 최대 10~12글자로 제한하지만, 이는 보안상 좋지 않습니다. (경우의 수는 많지만, 흔히 쓰는 패턴을 반영하면 경우의 수가 엄청나게 줄어듭니다.)
그리고 저장 효율은 비밀번호 길이와 무관합니다. 비밀번호 암호화는 해시 함수 특성상 고정 길이 결과를 반환하여, 사용자 비밀번호의 원문과 무관하게 같은 길이로 저장됩니다. 예를 들어 BCrypt는 60글자, SCrypt, ARGON2, PBKDF2 등은 설정에 따라 선택됩니다.
전송 효율과 서버 부담
우선 비밀번호 길이는 길게 쓰는 것을 최대한 권장해야 합니다. 마음만 같아서는 1000글자도 허용하고 싶지만 효율을 위해 길이 제한을 설정할 수 있고, 또 수만 글자를 허용하는 등의 스펙은 과도한 트래픽과 서버 자원 낭비를 유발하죠.
만약 20만 글자나 되는 비밀번호가 스프링 API 서버에 도착하면 어떨까요? 일단 메모리상에 매우 크고 임의적이고 불필요한 문자열이 상수 풀에 생성되면서, 요청을 받는 것만으로 서버 부담이 증가할 겁니다. 만약 글자 제한이 훨씬 짧았다면, 스프링 서버에 도착하기 전에 프록시 서버 등에서 로그인 요청의 바디 길이를 제한할 수 있었을 겁니다. 그러면 스프링 서버에 오기도 전에 이상한 요청을 막을 수 있었겠죠.
그래서 불필요할 정도로 너무 긴 비밀번호 길이보다는, 적당히 긴 길이까지만 입력을 받게 정책을 정하면 서버 운영의 부담을 덜 수 있습니다. 최대 길이는 수백 글자 범위를 추천하고, NIST는 사용자가 64글자 암호를 사용할 수 있다면 안전하다고 설명합니다.
예를 들어 대형 서비스인 구글은 100글자로 타이트하게 제한합니다. 트래픽과 부하가 있는 서비스이기 때문에 비교적 적은 글자를 허용하는 것으로 보이며, 이 정도로도 충분히 긴 암호를 사용할 수 있습니다.
충돌 저항성의 유지
길이 제한을 둔다고 해서 해시 충돌 확률이 줄어드는 것은 아닙니다. 잘 설계된 해시 함수는 길이 제한이 없더라도 해시 충돌의 확률이 충분히 비슷합니다. 대신, 굉장히 긴 비밀번호를 사용하는 사람이 우연히도 아주 짧고 쉬운 비밀번호와 해시 충돌이 생겨 계정이 탈취될 가능성이 아주 조금은 생기는데, 이는 길이 제한이 있는 시스템에서도 생길 수 있는 현상이며 그 확률이 거의 없다고 볼 수 있습니다.
비밀번호 최대 길이 10글자, 12글자가 절대 충분하지 않은 이유
10글자 암호의 경우의 수, 계산상 많지만...
영문 대소문자와 숫자로 된 10글자 암호는 62의 10제곱의 경우의 수가 존재합니다.
10글자 Alphanumeric Password의 경우의 수(대소문자 52, 숫자 10가지):
62의 10제곱 = 839,299,365,868,340,224가지
1초에 1억 개씩 연산할 수 있다고 할 때 모든 경우의 수를 계산하는 데에는 97,141일이 조금 넘게 걸리고, 이는 인간의 자연 수명을 아득하게 뛰어넘습니다. 266년이 넘는 것으로 계산되거든요. 하지만 사람들의 암호 작성 패턴을 반영하면 경우의 수가 극적으로 급감합니다.
사람들은 영문 소문자와 숫자로만 조합하기를 선호합니다.
사람들은 영문 소문자와 숫자를 번갈아 쓰는 경우가 거의 없습니다.
대부분 영문 소문자 조합 뒤에 숫자 조합을 붙입니다.
영문이나 숫자 중 하나로만 조합할 수도 있죠.
영문 소문자와 숫자를 번갈아 쓴다면 q1w2e3나 1q2w3e처럼 오히려 쉬운 패턴을 씁니다.
만약 소문자와 숫자로만 조합되는 비밀번호를 찍어 본다면 1초에 1억 개씩 계산할 때 약 14개월 정도로 기존 266년에 비하면 말도 안 되게 줄어듭니다. 여기서 영문자를 먼저 쓰고 숫자를 작성하는 것으로만 반영해도 훨씬 적은 경우의 수가 되겠죠. (10글자일 때 229,390,280,436,736가지 경우의 수가 있고 1초에 1억 개씩 총 26일 정도면 모든 경우의 수를 검토할 수 있습니다.)
평소 12글자보다 긴 암호를 사용해 온 사람들은 평소에 쓰던 안전하고 긴 비밀번호를 사용할 수 없으며, 기억하기 쉬운 새로운 비밀번호를 만들어서 사용하려고 할 것이고, 유추하기 쉬운 암호가 유도됩니다. 즉, 최대 10~12글자 제한은 사전공격을 준비할 때 힌트가 되겠죠.
흔히 "Tr0ub4dor&3" 같은 암호는 3일 만에 해독될 수 있다고 소개합니다. (troubador: 음유시인**)**
다른 해석들은 비밀번호가 길수록 안전하다는 것을 계산하여 설명합니다.
페퍼링
페퍼링은 가변 솔트보다 늦게 붙은 용어지만, 그 개념의 고안은 가변 솔트보다 한참 앞설 수도 있습니다. 대부분 서비스는 페퍼를 사용하지 않고 운영되므로, 메이저한 개념은 아닙니다.
페퍼는 '해시 값 노출'에서 생기는 위협으로부터 사용자를 한 층 더 보호하는 역할을 합니다.
솔트&페퍼: DB가 아는 가변 솔트와 DB는 모르는 고정 페퍼
가변 솔트는 사용자의 비밀번호와 함께 저장된다고 했습니다. 그렇다면 해시 값이 노출되었을 때 솔트도 함께 노출되겠죠. 그러면 공격자는 이 솔트를 바탕으로 사용자 비밀번호를 유추하기 위한 브루트포스를 준비할 수 있을 테구요. 그러다 우연히 맞는 게 있다면 드디어 정답을 하나 찾았다고 생각할 겁니다.
하지만 항상 그럴까요?
"그건 내 솔트의 일부일 뿐이다."
일부 민감한 서비스는 비밀번호와 함께 저장하는 솔트만 두지 않습니다. 아무리 DB를 털어 봐도 나오지 않는, '숨은 솔트'를 둘 수 있죠. 대신 DB에 저장하지 않는 만큼 '임의성'이 너무 다양하게 나타나지 않게 하는데, 기본 개념으로는 '서비스를 통틀어 하나의 값'이라고 이해할 수 있고, 이를 '페퍼'라고 부릅니다.
서비스 단일 솔트: 페퍼 (고정된 단일값)
사용자 암호별 솔트: 가변 솔트
그래서 공격자가 찾아 낸 암호는 사실 솔트의 '일부분'만 사용한 것이기 때문에, 공격자는 본인이 찾아 낸 암호를 사용해도 원래의 서비스에는 접속할 수 없습니다. 페퍼를 모르니 본인 컴퓨터에서 브루트포스를 해 봤자 사용자 암호를 알 수 없고, 결국 탈취한 해시 값은 무용지물이 되어 다시 서버에 브루트포스 공격 준비를 해야 합니다. 그건 굉장히 오랜 시간, 굉장히 느리게 수행하겠죠.
페퍼는 이렇게 해시 값 노출 시 위협으로부터 사용자를 한 층 더 보호하는 역할을 할 수 있습니다.
하지만 일반적으로 해시 값이 노출되는 일은 드물다고 보고, 대부분 페퍼는 사용하지 않습니다. 갱신 등 관리가 복잡하고, 해시 값 노출이 없는 한 페퍼의 효과가 크지는 않다고 보기 떄문이죠.
페퍼의 히스토리 관리
페퍼의 갱신은 번잡한 문제입니다. 페퍼가 적용되어 있는 비밀번호들은 모두 이 페퍼가 없으면 비밀번호 암호화를 할 수 없기 때문에 기존 페퍼도 보존되어야 하죠.
페퍼의 보존 위치
페퍼의 히스토리 관리는 이런 식으로 한다면 편할 것 같습니다.
최신본: 인증 서버 측에 바로 저장해 두고 사용. (인증 DB에 담지 않기)
Old (예전 버전들): 시간 등 버전을 정해서 인증 DB와 격리된 스토리지에 보존.
최신본은 앞으로 자주 조회될 거고, 예전 버전들은 조회를 적게 할 겁니다. 둘 다 최대한 인증 DB로부터 격리된 저장 공간을 사용해야 의미가 있습니다.
페퍼는 환경변수나 기타 수단으로 보존·전달하여 서버 프로그램이 바로 접근할 수 있게 합니다. 구버전은 기본적으로는 별개 저장소에 옮겨 담아도 무방하지만, 최근까지 쓰인 구버전은 호출 빈도가 높기 때문에 가까운 자원에 두어도 됩니다.
주기적으로 갱신되는 페퍼를 적용한 환경에서 비밀번호 비교
오래된 페퍼를 점점 호출하지 않는 이유는, 활성 사용자들이 오래된 페퍼로 로그인을 해도 갱신된 페퍼로 비밀번호를 다시 암호화하기 때문입니다.
사용자 입력
계정 (username)
비밀번호 (raw password)
DB에서 암호화된 비밀번호와 비밀번호 생성 시각 조회
암호화된 비밀번호: 비교용
비밀번호 생성 시각: 페퍼 조회용
비밀번호 생성 시각에 기반하여 어느 페퍼를 사용하는지 찾아서 페퍼 적용
비밀번호 비교(솔팅 및 반복 해싱)
- 통과 시 예전 페퍼를 사용 중이라면, 이번 로그인에 입력된 비밀번호를 사용해서 최신 페퍼를 적용해 DB에 담을 비밀번호를 갱신합니다.
이렇게 하여 기존 페퍼의 영향을 받는 비밀번호가 없다면, 그 페퍼는 삭제해도 되는 값이 됩니다.
주기적인 갱신을 하지 않는다면 페퍼의 의의가 다소 약할 것이며, 페퍼 값의 주기적인 갱신 시 페퍼 기반 로그인을 위한 프로세스는 대략 위와 같은 느낌일 겁니다.