최근 몇 년 사이에 브라우저 혹은 OS에서 제공하는 비밀번호 관리 시스템을 이용하는 사람이 많아졌다. 혹은 1Password와 같은 애플리케이션을 이용하는 사람도 적지 않다.

이런 제품을 사용한다면 한 번쯤 정말 안전한 게 맞을까?라는 의심을 해본 적이 있을 것이다. 이번 글에서는 금고 시스템이라고도 부르는 비밀번호 관리 시스템이 안전한 이유를 알아보고 금고 시스템을 직접 구현해 볼 것이다.

암호화에 대한 정의

이 글에 관심을 가진 개발자라면 대부분 암호화가 무엇인지 대략 알고 있을 것이다. 당연하지만 암호라는 것은 오래전부터 사용되어 왔고 많은 시행착오와 발전을 거쳐 개발 세계에 도입된 것이다. 그러니 먼저 암호화의 정의에 대해 알아보자.

암호학은 방해 행위로부터 프로토콜을 방어하는 것을 목표로 하는 과학이다. (...)
당신은 몇 시간 정도 마법검을 내려놓고 낮잠을 좀 자고 싶다. 이를 위한 프로토콜을 한 가지 구성해보면 다음과 같다.

  1. 땅에 무기를 내려 놓는다.
  2. 나무 아래서 낮잠을 잔다.
  3. 땅에서 무기를 들어 올린다.

물론 이는 좋은 프로토콜이 아니다. 낮잠을 자는 사이 누군가가 마법검을 훔칠 수 있기 때문이다. 여기서 암호학의 역할을 마법검을 훔쳐가려는 적의 존재를 고려하는 것이다.

- 리얼월드 암호학 1장 4쪽

위와 같이 비우호적인 존재로부터 프로토콜을 방어하는 것이 암호학의 목표라고 할 수 있다. 조금 더 단순하게 표현하자면 타인이 알 수 없도록 평문(Plain text)를 암호문(Cipher text)으로 바꿔서 전달하는 것을 의미한다.

간단한 사례를 살펴보면 원시적인 암호화 방법으로 카이사르 암호(시저 암호)라는 것이 있다. 카이사르 암호는 치환 암호의 일종으로 암호화하고자 하는 내용을 알파벳별로 일정한 거리만큼 밀어서 다른 알파벳으로 치환하는 방식이다. 예를 들어 3글자씩 밀어내는 카이사르 암호로 'COME TO ROME'을 암호화하면 'FRPH WR URPH'가 된다. 이렇게 암호화된 문장을 다시 복호화하려면 암호화할 때와 같은 거리만큼 밀어내면 된다. 여기서 프로토콜은 문장을 다른 사람에게 전달하는 것이고 3글자씩 밀어낸다는 것이 암호화의 핵심이다. 물론 이런 방식은 매우 취약하기에 현대에선 사용되지 않는다.

카이사르 암호는 각각의 알파벳을 일정한 거리만큼 밀어 글자를 치환하는 방식으로 암호화한다

만약 스포츠를 좋아한다면 실시간으로 전략을 정할 때 사인을 보낸다는 것을 알고 있을 것이다. 이때 전략이 유출되면 안 되기 때문에 팀 내에서 수신호의 의미를 정하고 외부에 공개하지 않는다. 그렇지만 상대편 또한 의미를 해석하려 하기에 키 사인(Key sign)을 사용하여 숨기기도 한다. 이 또한 암호화의 한 형태라고 할 수 있다.

다른 사람들은 이 수신호의 의미를 알 수 없다

이런 단순한 형태의 암호화를 거쳐 오늘날의 암호화는 복잡하고 검증된 알고리즘을 사용한다. 이런 알고리즘을 이용하면 비밀번호 관리 시스템을 만드는 것도 가능하다. 이 글에서는 프로토콜을 어떻게 안전하게 만들 것인가?를 다루는 것이 목적이며 알고리즘이 어떻게 이루어지는지는 다루지 않을 것이다. 따라서 복잡한 수학적인 지식은 필요하지 않다.

케르크호프스의 원칙

공개된 암호화 표준을 구축하는 것은 케르크호프스의 원칙이라는 개념과 관련이 있다. 이 원칙은 대략 다음과 같다 '우리가 가장 많이 사용하는 알고리즘을 적이 발견하지 못하리라고 기대하는 것은 어리석은 일이다. 차라리 적에게 공개적으로 개방하자'

- 리얼월드 암호학 1장 9쪽

카이사르 암호나 스포츠에서 사용되는 사인과 다르게 프로그래밍에 쓰이는 암호화 관련 알고리즘은 대부분 공개되어 있다. 앞서 소개한 사례는 알고리즘이 유출되는 경우 너무나도 쉽게 복호화가 가능하다. 그러니 알고리즘이 공개되었는데 정말 안전한가?라는 의문이 충분히 들 수 있다. 결론부터 말하자면 최소한 요즘 프로그래밍에 사용되는 알고리즘은 안전하다고 말할 수 있다.

케르크호프스 혹은 커코프라 불리는 원칙은 Auguste Kerckhoffs가 작성한 글인 군사용 암호 설계 원칙을 살펴보자. 한국어 위키백과에 실린 내용은 다음과 같다.

  1. 암호체계는 수학적으로는 해독불가능하지 않다고 하더라도, 실질적으로 그래야한다.
  2. 암호체계는 비밀에 부쳐질 필요가 없어야만 하며, 적의 손에 떨어지더라도 문제가 없어야 한다.
  3. 키는 글로 쓰여지지 않더라도 교환 혹은 보관할 수 있어야 한다. 당사자들의 의지에 의해서 바뀌거나 수정될 수 있어야 한다.
  4. 전신에 적용할 수 있어야 한다.
  5. 이동이 가능해야하며, 암호 체계의 사용과 기능을 위해 여러 사람의 협력을 필요로 하지 않아야 한다.
  6. 마지막으로, 시스템의 활용을 요구하는 여러 상황들이 주어졌을 때, 암호 체계는 이용이 쉬워야 하며, 정신적인 압박감이나 여러 규칙들의 관찰을 필요로 하지 않아야 한다.

이 중 두 번째 암호체계는 비밀에 부쳐질 필요가 없어야만 하며, 적의 손에 떨어지더라도 문제가 없어야 한다가 케르크호프스의 원칙으로 알려졌으며 소프트웨어적으로 표현한다면 암호화 알고리즘이 노출되더라도 안전해야 한다고 할 수 있다. 이 말처럼 이미 HTTPS나 이 글에서 다루는 비밀번호 관리 시스템 등 많은 영역에서 AES, RSA 등 공개된 암호화 알고리즘을 사용하지만 충분히 안전하다.

알고리즘이 공개되어도 안전할 수 있는 이유는 비밀 키에 있다. 현대에서 사용되는 대부분의 알고리즘은 비밀 키를 다른 사람이 모른다면 알고리즘이 알려지더라도 암호문을 안전하게 전달할 수 있다. 즉, 비밀 키를 통해 알고리즘이 아닌 프로토콜을 안전하게 만드는 것이다. 그러니 오히려 알고리즘을 공개하여 더 많은 사람들이 검증하고 개선할 수 있도록 하는 것이 더 안전하다고 할 수 있다. 우리가 사용하는 암호화 알고리즘은 많은 사람들이 허점을 찾는 것에 도전했으며 그 많은 도전을 이겨내고 남은 것이다.

고전 암호는 왜 위험한가?

말은 쉽지만 공개되어도 안전한 알고리즘을 만드는 것은 쉬운 일이 아니다. 오늘날의 컴퓨터는 사람이 절대 따라갈 수 없는 엄청난 연산량을 가지고 있다. 반면 컴퓨터가 등장하기 전 고전 암호라 불리는 암호 체계는 이러한 연산량을 고려하여 탄생하지 않았다.

예를 들어 앞서 사용했던 카이사르 암호는 특정한 숫자를 비밀 키로 사용하여 알파벳을 밀어내지만 비밀 키를 모르더라도 해독하는 것이 어렵지 않다. 알파벳은 겨우 26개이기 때문에 모든 경우의 수를 다 해보면 쉽게 해독할 수 있고 컴퓨터는 26개 정도는 순식간에 처리할 수 있다.

그러면 조금 더 복잡한 고전 암호는 어떨까? 16세기에 지오반 바티스타 벨라소라는 이탈리아인이 만든 비즈네르 암호가 있다.1 원리는 카이사르 암호와 비슷하지만 키와 알고리즘이 조금 더 복잡하다. 만약 키가 DUFHI THERE이라는 문장을 암호화한다면 KU VJZUJ가 된다.

키가 DUF인 경우

이 암호의 핵심은 키에 해당하는 알파벳 위치가 특정수를 나타내고 그 수만큼 평문을 연쇄적으로 밀어내는 것이다. 따라서 D, U, F는 각각 3, 20, 5를 의미하고 HI THERE을 3-20-5-3-20.. 순으로 밀어내면 KU VJZUJ가 된다. 비즈네르 암호는 키의 길이가 고정이 아니기 때문에 사람 손으로 해독하기엔 많은 경우의 수를 고려해야해서 어려울 것이다.

다만 컴퓨터 입장에서 그다지 어렵지 않다. 빈도분석을 이용하면 쉽게 해독할 수 있다. 빈도분석은 언어에서 글자들의 분포가 고르지 않다는 점을 활용한다. 예를 들어 영어에서는 E가 가장 많이 나오는 글자이고 Q가 가장 적게 나오는 글자이다. 따라서 암호문에서 가장 많이 나오는 글자를 E로 해석하고 가장 적게 나오는 글자를 Q로 해석하면서 해독해 나가면 된다. 그리고 이러한 확률적인 계산과 반복적인 대입은 컴퓨터가 쉽게 할 수 있는 일이다.

비즈네르 암호는 이처럼 비교적 약한 암호이지만, 실제로 쓰이던 시절에는 메시지를 안전하게 암호화하는 용도로 충분히 쓸만했을 것이다. (...) 당시 비밀리에 전송한 메시지들은 대부분 아주 짧은 기간만 비밀을 유지하면 되었다. 따라서 언젠가 적이 암호를 해독한다고 해도 문제가 되지 않았다.

- 리얼월드 암호학 1장 33쪽

그러면 현대 암호는 안전한가?

고전 암호는 전수 키 탐색, 빈도 수 공격 등에 매우 취약하다. 특히 카이사르 암호와 같이 키 공간2이 부족하고 언어적 특성이 바로 반영되는 경우엔 암호로서 큰 의미가 없다고 볼 수 있다. 그럼 현대 암호는 뭐가 다를까?

예를 들어 현대에 사용되는 대칭 암호화는 혼돈과 확산이라는 개념을 극대화한 알고리즘을 사용한다. 혼돈과 확산은 정보 이론의 창시자라 할 수 있는 클로드 섀넌이 제시했으며 혼돈은 암호문에서 키를 알아내기 어렵게 하는 성질을 말하며 확산은 암호문에서 원본 메세지를 알아내기 어렵게 하는 성질을 말한다. 현대 암호는 이를 잘 달성했는지 여부가 암호화의 안전성을 판단하는 기준이 된다.

인크립션: 실용주의 암호화 / 길벗

그러면 혼돈과 확산은 어떻게 달성하는가? 혼돈은 원래 대상과 다른 대상을 섞어 원래 대상이 무엇인지 알아볼 수 없만들고 확산은 원래 대상이 암호문에서 최대한 넓은 부분으로 퍼지도록 만든다. 이때 혼돈을 달성하기 위해 Substitution대체을 사용하고 확산을 달성하기 위해 Permutation순열을 사용한다. 말 그대로 Substitution은 'ABCA'와 같은 문자열이 있다면 '1231'과 같이 대체하는 것을 말하고 Permutation은 'ABCA'와 같은 문자열이 있다면 'BCAA'와 같이 순서를 바꾸는 것을 말한다.

Substitution과 Permutation을 한 번 수행하는 것을 SPN(Substitution-Permutation Network)이라고 하며 현대 암호는 SPN을 여러번 반복하는 것으로 어마어마한 경우의 수를 만들어낸다. 즉, 원본 키에 대한 혼돈과 확산을 적용하여 원본을 알아낼 수 없을 정도로 어마어마한 경우의 수를 만들어 컴퓨터의 엄청난 연산량으로도 복호화를 해내지 못하게 만든다면 충분한 안정성을 달성했다고 볼 수 있다.

이런 경우를 제외하면 복호화는 불가능하다 / 고무호스 암호분석

세 가지 암호화 방식

보통 현대적인 암호화 알고리즘은 세 가지 방식으로 나뉜다. 각각 대칭 암호화, 비대칭 암호화, 해싱으로 이 세 가지 방식은 각각 다른 목적으로 사용되며 서로 다른 특징을 가지고 있다.

대칭 암호화

대칭 암호화(Symmetric Encryption)는 하나의 비밀 키(혹은 대칭 키)를 이용하여 암호화하고 복호화하는 알고리즘을 의미한다. 앞서 소개한 모든 암호 알고리즘은 대칭 암호화에 속한다.

보통 블록 암호화 알고리즘인 AES 알고리즘3을 사용하는 경우가 많다. 여기서 해당 알고리즘의 자세한 동작을 설명하지는 않을 것이다. 만약 AES 알고리즘 동작을 알고 싶다면 시각화가 잘 되어있는 유튜브 영상을 살펴보자.

참고로 대칭 암호화로 AES를 사용한다면 비밀 키를 탈취당하는 것을 제외하고 사실상 복호화가 불가능하다. 앞서 설명한 SPN을 여러번 실행하기 때문에 원본 키를 알아내는 것은 거의 불가능하므로 무차별 대입으로 전수 키 조사를 하는 것이 그나마 실질적인 공격이라 할 수 있다. 충분히 복잡하고 긴 키를 사용하는 경우 슈퍼 컴퓨터가 오더라도 의미있는 시간4 내에 복호화할 수 없다.

비대칭 암호화

비대칭 암호화(Asymmetric Encryption)는 두 개의 키를 이용하여 암호화하고 복호화하는 알고리즘을 의미한다. 이 두 개의 키는 서로 다르며 하나는 공개 키(Public Key)이고 다른 하나는 개인 키(Private Key)라고 부른다.

공개 키는 누구나 알 수 있지만 개인 키는 오직 소유자만 알 수 있다. 이 두 키를 이용하여 암호화하면 공개 키로 암호화한 것은 개인 키로만 복호화할 수 있고 개인 키로 암호화한 것은 공개 키로만 복호화할 수 있다. 이를 이용하여 안전하게 통신 할 수 있다. 이미 대칭 암호화가 있는데 왜 비대칭 암호화를 사용할까?

대칭 암호화는 서로 암호를 주고 받기 위해서 최소한 한 번은 비밀 키를 공유할 필요가 있다. 이는 많은 사례에 적합하지만 참여자가 많은 경우엔 문제가 생긴다. 참여자 중 한명이 비밀 키를 유출하지 않으리라는 보장을 할 수 있을까? 현실적으로는 불가능하다. 따라서 만약 많은 사용자가 접속하는 웹사이트에 암호문을 전달해야 한다면 대칭 암호화는 사실상 의미가 없다. 그래서 비대칭 암호화가 필요해졌다. 참고로 이러한 문제를 Key Distribution키 배포라고 한다. 이 문제는 오랫동안 난제였지만 디피-헬먼 키 교환 알고리즘을 거쳐 RSA 알고리즘5을 통해 해결되었다.

다시 설명으로 돌아와 비대칭 암호화는 받을 대상의 공개 키로 암호화 할 수 있으므로 누구나 암호화할 수 있지만 복호화는 오직 받을 대상의 개인 키로만 가능하다. 따라서 참여자가 많더라도 안전하게 통신할 수 있다.

해싱

해싱은 엄밀히 암호화는 아니며 임의의 길이의 데이터를 고정된 길이의 데이터로 변환하는 것을 의미한다. 이 변환된 데이터를 해시 값이라고 부르며 해시 함수를 이용하여 변환한다. 해싱은 앞서 소개한 암호화와는 다르게 복호화가 불가능하다. 즉, 해시 값으로 원본 데이터를 알아낼 수 없다. 그럼 보안 측면에서 해싱을 사용하는 의미가 있을까?

앞서 저장한 것을 검증할 수 있다

해싱은 무결성 검증에 사용할 수 있다. 무결성 검증이란 데이터가 변조되지 않았는지 확인하는 것을 의미한다. 예를 들어 파일을 다운로드 받을 때 파일의 해시 값을 함께 제공하면 다운로드 받은 파일이 변조되지 않았는지 확인할 수 있다. 또한, 비밀번호를 저장할 때도 해싱을 사용한다. 비밀번호를 해싱하여 저장하면 원본 비밀번호를 알 수 없으며 해시 값만 알 수 있다. 따라서 해시 값이 유출되더라도 원본 비밀번호를 알 수 없다.

해싱 함수 중에는 대표적으로 SHA6가 있다. SHA-1, SHA-256, SHA-512 등이 있으며 각각 160비트, 256비트, 512비트의 해시 값을 생성한다. 이 중 SHA-256은 안정성과 성능을 고려하여 현재 많이 사용되는 해싱 함수다. 다만, 레인보우 테이블이나 해시 충돌 등의 공격을 당할 수 있기에 복호화가 불가능하다해서 해싱이 안전하다고 볼 수는 없다. 따라서 솔트Salt와 키 스트레칭Key Stretching을 사용하여 보완해야 한다.

솔트는 재료에 소금을 곁을여 먹는 것에 비유한 것으로 평문에 임의의 문자열을 추가하여 암호화하는 방법을 말한다. 그리고 키 스트레칭은 해시를 여러번 반복하여 원문을 알기 힘들게 만드는 방법이다. 이런 방법을 사용하면 공격자가 해싱 알고리즘을 실행하는 데에 시간이 오래걸리므로 암호화 해독이 어려워진다. 해싱 함수와 솔트, 키 스트레칭을 묶은 Bcrypt, PBKDF2 등의 알고리즘이 있다.

개인 금고

이제 본격적으로 비밀번호 관리 시스템과 연관된 개인 금고를 구현해볼 것이다.

가벼운 개인 금고 시스템 구현은 기본적으로 두 가지 개념만 알아도 된다. 바로 마스터 패스워드대칭 암호화다. 실제 제품에는 보안을 위해 더 많은 기술이 필요하겠지만 가장 핵심적인 기술은 이 두 가지라고 할 수 있다.

마스터 패스워드

마스터 패스워드는 비밀번화 관리 시스템의 주인임을 인증할 수 있는 비밀 키를 의미한다. 사용자는 마스터 패스워드를 통해 비밀번호 관리 시스템 인증을 통과하고 비밀번호를 보관하거나 확인할 수 있다. 이말은 마스터 패스워드이 유출되면 모든 비밀번호가 유출된다는 것을 의미한다. 또한, 유실할 경우 저장된 모든 비밀번호를 알 수 없게 되버린다. 따라서 이 마스터 패스워드는 안전하게 보관해야 한다. 다만, 유실의 경우 다른 특수한 기술을 사용하여 복구하는 방법이 있을 수 있다.

보통 사용자가 입력한 마스터 패스워드는 해시를 만든 후 진짜 사용자인지 인증하는 데에 사용한다. 즉, 웹사이트에 사용하는 비밀번호와 크게 다르지 않다. 다만, 아무래도 이 비밀번호는 모든 비밀번호를 관리하는 열쇠이기에 더욱 안전하게 만들어지고 보관해야 한다.

마스터 패스워드를 비롯하여 안전한 비밀번호는 다음과 같은 특징을 가지고 있다. 만약 비밀번호 관리 시스템을 사용하다면 가급적 다음 수칙을 지키는 것이 좋다.

  1. 최소 16자 이상 - 길이가 길수록 안전하다
  2. 재사용된 비밀번호가 아닐 것 - 다른 웹사이트에서 사용한 비밀번호를 사용하지 않을 것
  3. 복잡성을 가질 것 - 대소문자, 숫자, 특수문자를 조합하여 사용
  4. 개인 정보가 포함되지 않을 것 - 이름, 생일, 주소 등 개인 정보가 포함되지 않을 것
  5. 사전에 등록된 단어가 아닐 것 - Dictionary Attack을 방지하기 위해 사전에 등록된 단어가 아닐 것

비밀번호 저장하기

보관할 비밀번호는 마스터 패스워드의 해싱 값을 이용하여 대칭 암호화한 후 저장하면 된다. 이때 주의할 점으로 마스터 패스워드의 해싱 값은 절대로 저장해서는 안된다. 마스터 패스워드의 해싱 값을 대칭 키로 이용하면 굳이 마스터 패스워드를 몰라도 저장된 해싱 값을 알아내면 복호화하는 것이 가능하다. 이를 Pass-the-Hash 공격이라고 한다. 이는 보통 웹 서비스에서 비밀번호를 해싱하여 저장하는 습관에서 비롯된 것으로 많은 사람들이 실수하기 쉬운 부분이므로 주의할 필요가 있다.

아주 간단한 도식

마스터 패스워드의 해싱 값은 어딘가에 저장되지 않고 클라이언트에 잠깐 거쳐가거나 클라이언트 메모리에 짧은 기간 동안 보관되어야 한다. 편의를 위해 영구 보관할 수도 있겠지만 보안에 취약하다. 클라이언트가 마스터 패스워드 해싱 값을 알게되면 스토어에 새로운 비밀번호를 암호화하여 저장하거나 가져온 값을 복호화 하는 것이 가능하다. 이렇게 하면 사용자는 하나의 마스터 패스워드로 모든 비밀번호를 관리할 수 있게 된다.

마스터 언록 키 생성

앞서 마스터 패스워드가 유출되면 모든 비밀번호를 유출된다고 언급했다. 마스터 패스워드만 유출되지 않으면 안전하다는 것은 알 수 있지만 솔직히 사용하기엔 불안하다. 이를 해결하기 위해 한 가지 아이디어를 떠올려보자. 대칭 키로 마스터 패스워드의 해싱 값만 사용하는 것이 아니라 사용자의 디바이스에 종속된 랜덤한 키를 추가로 사용하면 어떨까? 예를 들어, 마스터 패스워드의 해싱 값과 랜덤 생성된 비밀 키를 XOR 연산하여 대칭 키로 사용하는 것이다. 이렇게 사용한다면 마스터 패스워드가 유출되더라도 디바이스 자체를 탈취당하지 않는다면 안전하다.

아주 간단한 도식

랜덤 생성된 비밀 키는 각 OS에서 사용되는 암호 저장 서비스를 이용하여 생성, 보관하면 된다. macOS라면 Keychain, Windows라면 Credential Manager을 사용할 수 있다.7

구현

이제 이론적인 부분을 끝내고 실제로 구현해보자. 전체 프로세스에 대한 도식은 다음과 같다.

전체 프로세스 도식

CLI 툴로 구현한다고 가정했을 때 필요한 기능은 다음과 같다.

  • AES 암호화
  • 해싱 (여기서는 PBKDF2를 사용)
  • 랜덤 비밀 키 생성
  • 마스터 언록 키 생성
  • 새로운 비밀번호 추가
  • 저장된 비밀번호 확인
  • 저장된 비밀번호 삭제
  • 마스터 패스워드 인증

여기서는 코드 예제로 쉽게 테스트할 수 있는 Python 3를 사용할 것이다. 그렇게 어려운 코드는 아니므로 다른 언어로도 쉽게 구현할 수 있을 것이다.

먼저 AES 암호화 모듈을 만들어보자. 이를 위해 pycryptodome을 설치해서 사용할 것이다.

$ pip install pycryptodome

설치를 마쳤다면 다음 코드를 작성해보자.

# aes.py

import base64
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad


class AESCipher(object):
    def __init__(self, key: str):
        self.key = hashlib.sha256(key.encode()).digest()

    def encrypt(self, message: str) -> str:
        message = message.encode()
        raw = pad(message, AES.block_size)
        cipher = AES.new(self.key, AES.MODE_CBC)
        enc = cipher.encrypt(raw)

        ciphertext = base64.b64encode(enc).decode('utf-8')
        iv = base64.b64encode(cipher.iv).decode('utf-8')

        return ciphertext, iv

    def decrypt(self, enc: str, iv: str) -> str:
        enc = base64.b64decode(enc)
        cipher = AES.new(self.key, AES.MODE_CBC, base64.b64decode(iv))
        dec = cipher.decrypt(enc)
        return unpad(dec, AES.block_size).decode('utf-8')


if __name__ == "__main__":
    aes = AESCipher("key")
    encrypted, iv = aes.encrypt("password")
    print(encrypted) # 6F9aHBhcbWhExUkVjBWFvw== or something like this
    decrypted = aes.decrypt(encrypted, iv)
    print(decrypted) # password

AESCipher 클래스는 평문의 암호화와 복호화를 담당한다. 참고로 코드 중 ivInitialization Vector의 약자로 암호화할 때 사용하는 초기화 벡터를 의미한다. 이는 암호화할 때 사용한 iv를 복호화할 때도 사용해야 한다. 이를 통해 암호화된 메세지가 같은 메세지라도 매번 다른 암호문을 생성할 수 있다.

그리고 padunpad는 문자열에 패딩을 추가하고 제거하는 함수이다. 이를 통해 암호화할 때 블록 크기의 배수로 만들어주고 복호화할 때 다시 제거해준다. 더 자세한 내용은 AES 알고리즘을 참고하자.

다음으로 해싱 모듈을 만들어보자.

# hash.py

import base64
import hashlib
from Crypto.Protocol.KDF import PBKDF2


def hash_password(password: str, salt: str) -> str:
    return base64.b64encode(PBKDF2(password, salt, dkLen=48, count=100000)).decode('utf-8')

def hash_sha256(password: str) -> str:
    return hashlib.sha256(password.encode()).hexdigest()


if __name__ == "__main__":
    print(hash_password("password", "salt")) # ... 48 length hash
    print(hash_sha256("password")) # ... 48 length hash

hash_password 함수는 PBKDF2를 이용하여 해싱한 후 base64로 인코딩하여 반환한다. 이때 dkLen은 해싱된 길이를 의미하며 count는 해싱 반복 횟수를 의미한다. 이 값이 클수록 보안이 높아지지만 속도가 느려진다. hash_sha256 함수는 SHA-256 해싱을 수행한다.

이제 기본적인 암호화 모듈을 만들었으니 랜덤 비밀 키를 생성해 비밀번호 관리 서비스에 등록하는 모듈을 만들어보자. 여기서는 macOS를 기준으로 작성한다.

# random_key.py

import base64
import subprocess
import os


def generate_random_key() -> bytes:
    return base64.b64encode(os.urandom(32)).decode('utf-8')

def keychain_get_password(service, account):
    command = f"/usr/bin/security find-generic-password -s '{service}' -a '{account}' -g -w"
    result = subprocess.run(command, shell=True, capture_output=True)
    password = result.stdout.decode().strip()
    return password

def keychain_store_password(service, account, password):
    cmd = 'security add-generic-password -U -a %s -s %s -p %s' % (account, service, password)
    p = os.popen(cmd)
    s = p.read()
    p.close()


if __name__ == "__main__":
    # If password exists, print it
    if not keychain_get_password("local-vault", "manager"):
        print("Password does not exist")
        keychain_store_password("local-vault", "manager", generate_random_key())
        print("Password stored")

    print(keychain_get_password("local-vault", "manager")) # print random key

위 코드를 실행하면 macOS 키체인에 정상적으로 값이 들어간 것을 확인할 수 있다.

입력한 값으로 키체인에 추가되어있다

이제 기반 모듈은 모두 만들었다. 이제 이를 이용하여 애플리케이션을 만들어 볼 것이다. 먼저 가장 중요한 마스터 언록 키를 생성하는 모듈을 만들어보자.

# muk.py

import base64
from hash import hash_password, hash_sha256
from random_key import generate_random_key, keychain_get_password, keychain_store_password


def xor_two_str(a: str, b: str) -> str:
    a = base64.b64decode(a)
    b = base64.b64decode(b)
    return base64.b64encode(bytes([x ^ y for x, y in zip(a, b)])).decode('utf-8')

def generate_master_unlock_key(master_password: str) -> str:
    hashed_master_password = hash_password(master_password, "salt")

    if not keychain_get_password("local-vault", "manager"):
        keychain_store_password("local-vault", "manager", generate_random_key())

    random_secret_key = keychain_get_password("local-vault", "manager")
    hashed_random_secret_key = hash_sha256(random_secret_key)

    return xor_two_str(hashed_master_password, hashed_random_secret_key)


if __name__ == "__main__":
    print(generate_master_unlock_key("master_password")) # ... 42 length string

마스터 패스워드를 PBKDF2로 해싱한 값과 랜덤 비밀 키를 SHA-256으로 해싱한 값을 XOR 연산하여 마스터 언록 키를 생성한다. 이렇게 생성된 마스터 언록 키는 사용자의 디바이스에 종속된 키가 되어 모든 비밀번호를 관리할 수 있게 된다.

이제 이를 이용하여 비밀번호 관리 시스템의 기본 기능을 구현해보자. 먼저 CLI 애플리케이션이기 때문에 argparse를 사용하여 명령어를 파싱할 것이다.

# main.py
# import ...

class App:
    def setup(self):
        master_password = getpass.getpass("Enter master password: ")
        self.muk = generate_master_unlock_key(master_password)
        self.aes = AESCipher(self.muk)

    def run(self):
        parser = argparse.ArgumentParser(description="Vault: Add, Update, Delete and Query Passwords", usage="[options]")

        parser.add_argument("-a", "--add", action="store_true", help="Add new password along with name")
        parser.add_argument("-u", "--update", type=str, nargs=1, help="Update a password by name", metavar=("[name]"))
        parser.add_argument("-d", "--delete", type=str, nargs=1, help="Delete entry by name", metavar=("[name]"))
        parser.add_argument("-q", "--query", type=str, nargs=1, help="Look up password by name", metavar=("[name]"))
        parser.add_argument("-l", "--list", action="store_true", help="List all entries in vault")

        args = parser.parse_args()

        if args.add:
            print('add')
        elif args.update:
            print('update')
        elif args.delete:
            print('delete')
        elif args.query:
            print('query')
        elif args.list:
            print('list')
        else:
            parser.print_help()

위와 같이 추가, 수정, 삭제, 조회 등의 명령어를 파싱할 수 있도록 구현했다. 이제 이를 이용하여 비밀번호를 추가, 수정, 삭제, 조회하는 기능을 구현해보자. 먼저 추가부터 구현할 것이다.

# main.js

class App:
    # ...

    def add(self):
        name = input("Enter item name: ")
        password = getpass.getpass("Enter password: ")
        cipher, iv = self.aes.encrypt(password)

        ## Read from file as JSON
        if os.path.isfile("vault.json") == False:
            data = {}
        else:
            file = open("vault.json", "r")
            data = json.load(file)
            file.close()

        ## Update JSON
        data[name] = {"cipher": cipher, "iv": iv}

        ## Save to file as JSON
        file = open("vault.json", "w")
        json.dump(data, file)
        file.close()

        print(f"Entry {name} added")

추가 명령이 들어오는 경우 사용자로부터 이름과 비밀번호를 입력받아 암호화한 후 파일에 저장한다. 이때 파일은 JSON 형식으로 저장하며 이름과 암호화된 비밀번호, IV를 저장한다. 다음으로 조회 기능을 구현해보자.

# main.js

class App:
    # ...

    def list(self):
        if os.path.isfile("vault.json") == False:
            print("No entries found")
            return

        file = open("vault.json", "r")
        data = json.load(file)
        file.close()

        for entry in data:
            print(f"* {entry}")

    def query(self, name):
        if os.path.isfile("vault.json") == False:
            print("No entries found")
            return

        file = open("vault.json", "r")
        data = json.load(file)
        file.close()

        if name in data:
            cipher = data[name]["cipher"]
            iv = data[name]["iv"]
            password = self.aes.decrypt(cipher, iv)
            print(f"Password for {name}: {password}")
        else:
            print("Entry not found")

목록 조회와 조회 기능을 구현했다. 목록 조회는 파일에 저장된 모든 이름을 출력하고 조회 기능은 입력받은 이름에 해당하는 비밀번호를 복호화하여 출력한다. 마지막으로 수정과 삭제 기능을 구현해보자.

# main.js

class App:
    # ...

    def delete(self, name):
        if os.path.isfile("vault.json") == False:
            print("No entries found")
            return

        file = open("vault.json", "r")
        data = json.load(file)
        file.close()

        if name in data:
            del data[name]

            file = open("vault.json", "w")
            json.dump(data, file)
            file.close()

            print(f"Entry {name} deleted")
        else:
            print("Entry not found")

    def update(self, name):
        if os.path.isfile("vault.json") == False:
            print("No entries found")
            return

        file = open("vault.json", "r")
        data = json.load(file)
        file.close()

        if name in data:
            password = getpass.getpass("Enter new password: ")
            cipher, iv = self.aes.encrypt(password)
            data[name] = {"cipher": cipher, "iv": iv}

            file = open("vault.json", "w")
            json.dump(data, file)
            file.close()

            print(f"Entry {name} updated")
        else:
            print("Entry not found")

이제 모든 기능을 구현했다. 구현된 코드는 다음과 같이 사용할 수 있다. 참고로 지금까지 작성한 코드는 GitHub 저장소에서 확인할 수 있다.

$ python main.py -a
Enter master password: 
Enter item name: Google
Enter password: 
Entry Google added

$ python main.py -l
* Google

$ python main.py -q Google
Enter master password:
Password for Google: password # Your password

$ python main.py -u Google
Enter master password:
Enter new password:
Entry Google updated

$ python main.py -d Google
Entry Google deleted

마스터 언록 키를 캐싱하는 등 서비스를 위한 편의 기능은 여기서 구현하지 않았다. 만약 이 글의 내용이 재미있었다면 이어서 추가 구현해보는 것도 좋을 것이다.

로컬 금고를 넘어서

지금까지 다룬 것은 로컬에서 사용 가능한 금고로 제 3자가 관리하는 네트워크 환경에서는 사용할 수 없다. 또한, 1password와 같은 서비스에서 제공하는 여러 명이서 접근 가능한 공유 금고는 다루지 않았다. 이 두 가지를 위해선 추가적으로 필요한 기술이 있다. 이 기술에 대해선 이번 글에서 다루지는 않을 것이다. 조만간 새로운 글로 다루도록 하겠다.

마치며

암호라는 것은 모든 것이 공개되는 인터넷 세계에선 필수라고 할 수 있다. 그럼에도 불구하고 필자를 포함한 많은 개발자들이 암호에 대해 잘 알지 못한다. 이 도메인을 전문적으로 다루거나 종사하는 것이 아니라면 모든 것을 다 알아야할 필요는 없겠지만 단순히 패스워드를 해싱하는 수준에서 벗어나 더 넓은 세계를 탐구해보는 것은 좋을 것이다.

참고로 이 글에서 쓰여진 코드는 정말 안전한지 검증된 것은 아니다. 따라서 이 글을 참고하여 실제 제품에 반영하고자 한다면 충분히 검증 후 도입하기를 권장한다. 여기서 사용된 알고리즘은 보편적으로 사용하는 것을 택했으며 더 안전한 방법이 있을 수 있다.

Footnotes

  1. 비즈네르는 다른 암호를 만든 블레즈 드 비즈네르라는 프랑스 인이지만 역사상의 인용 오류 때문에 그냥 비즈네르라는 이름이 굳어버렸다 / 처음 배우는 암호화 1장 32쪽

  2. 키가 될 수 있는 조합 수

  3. Advanced Encryption Standard의 약자로 미국 표준 기술 연구소에 의해 표준으로 지정된 암호화 방식이다

  4. 태양계의 수명이 끝나도 원본 키를 찾을 수 없다

  5. 만든 사람의 이름인 Ron Rivest, Adi Shamir, Leonard Adleman에서 따온 이름이다

  6. 이름부터 Secure Hash Algorithm, 안전한 해시 알고리즘이다

  7. Linux 계열 OS는 어떤 방법을 사용할 수 있을지 모르겠다. 추후에 알게되면 추가하겠다.