Engineering

비밀 공유를 위한 금고 시스템 만들기

키 계층, 영지식 경계와 공유 흐름을 이용해 암호화 금고 시스템을 설계합니다.

검증일 근거 자료

2년 전 비밀 관리를 위한 금고 시스템 만들기라는 글에서 마스터 패스워드와 대칭 암호화만으로 개인용 금고를 만들어봤다. 글 말미에 여러 명이서 접근 가능한 공유 금고는 조만간 새로운 글로 다루겠다고 적어두고는 꽤 오랫동안 미뤄뒀는데, 이제야 그 숙제를 마치려 한다.

공유 금고는 개인 금고와 문제의 결이 다르다.1 개인 금고는 "자신의 비밀을 자신만 볼 수 있게 한다"는 문제였다. 공유 금고는 여기에 모순처럼 들리는 조건이 하나 붙는다. 비밀을 주인만 볼 수 있게 하되, 주인이 허락한 사람도 볼 수 있어야 한다. 심지어 두 사람은 서로의 마스터 패스워드를 모르고, 서버도 두 사람의 비밀을 몰라야 한다. 1Password의 공유 금고나 AWS Secrets Manager 같은 제품이 정확히 이 문제를 푼다.

이번 글에서는 이 모순을 어떤 아이디어로 풀어내는지 알아보고, 전작과 마찬가지로 Python으로 직접 구현까지 해볼 것이다. 전작을 읽지 않았더라도 따라올 수 있도록 필요한 개념은 다시 설명하겠지만, 대칭 암호화·비대칭 암호화·해싱이 낯설다면 전작을 먼저 읽고 오는 것을 추천한다.

비밀은 혼자 쓰지 않는다

먼저 이 문제가 왜 존재하는지부터 짚어보자. 개인의 비밀번호는 혼자 쓰지만, 회사의 비밀은 그렇지 않다.

팀에서 개발을 해봤다면 이런 장면이 익숙할 것이다. 신규 입사자가 로컬 환경을 세팅하다가 막혀서 물어본다. "혹시 .env 파일 좀 받을 수 있을까요?" 그러면 누군가 슬랙 DM으로 파일을 툭 던져준다. 데이터베이스 비밀번호, 외부 API 키, JWT 서명 키가 통째로 들어있는 파일이 평문 그대로 메신저 서버를 통과한다. 고백하자면 필자도 그 파일을 던져주는 쪽이었던 적이 있다. 나름 체계를 갖춘 팀이라면 노션 어딘가에 "개발 환경 변수"라는 문서가 있고, 거기에 운영 DB 비밀번호까지 정리되어 있다.

이 방식의 문제는 유출 그 자체보다 통제 불가능성에 있다. 슬랙에 올라간 비밀은 검색하면 나온다. 퇴사자가 나가도 그 사람이 받아간 비밀은 회수할 수 없고, 어떤 비밀을 누가 언제 봤는지 아무도 모른다. 비밀번호를 바꾸는 것 말고는 되돌릴 방법이 없는데, 어디까지 퍼졌는지 모르니 무엇을 바꿔야 하는지도 모른다. 실제로 2022년 Uber 침해 사고에서 공격자는 내부 네트워크 공유 폴더에 있던 PowerShell 스크립트에서 하드코딩된 권한 관리 시스템의 관리자 계정을 발견했고, 이를 통해 사내 거의 모든 시스템의 비밀에 접근했다.2 비밀이 "그냥 어딘가에 적혀 있는" 상태가 얼마나 위험한지 보여주는 사례다.

그래서 팀 단위의 비밀 관리 도구가 필요해진다. 1Password, Bitwarden 같은 패스워드 매니저의 공유 금고 기능이나 AWS Secrets Manager, HashiCorp Vault 같은 인프라용 제품이 이 자리를 채운다. 이런 도구는 공통적으로 다음을 약속한다.

  • 비밀은 항상 암호화된 상태로 저장·전송된다
  • 권한이 있는 멤버만 복호화할 수 있다
  • 멤버를 추가하거나 제거할 수 있다
  • 누가 언제 접근했는지 기록이 남는다

문제는 이 약속을 지키는 방법이다. 특히 첫 번째와 두 번째를 동시에 지키는 것이 어렵다. 암호화된 비밀을 여러 명이 복호화하려면 열쇠를 나눠 가져야 하는데, 열쇠를 나누는 행위 자체가 또 하나의 비밀 공유 문제이기 때문이다.

개인 금고로는 왜 안 되는가

전작에서 만든 개인 금고의 구조를 요약하면 이렇다. 사용자의 마스터 패스워드를 PBKDF2로 해싱하고3, 디바이스에 저장된 랜덤 키와 XOR 연산(두 비트열을 섞어 하나의 값으로 만드는 연산)으로 합쳐 마스터 언록 키를 만든 뒤, 이 키로 비밀들을 AES 대칭 암호화해서 저장한다. 복호화 열쇠가 마스터 패스워드로부터 유도되므로, 마스터 패스워드를 아는 주인 본인만 금고를 열 수 있다.

이 구조를 그대로 공유에 쓰려고 하면 바로 벽에 부딪힌다. 열쇠가 곧 마스터 패스워드다. 동료에게 금고를 열어주려면 마스터 패스워드를 알려주는 수밖에 없는데, 그 순간 동료는 그 금고뿐 아니라 주인의 모든 비밀에 접근할 수 있게 된다. 금고 하나를 빌려주려다 집 전체를 내주는 꼴이다.

그러면 팀 전용 마스터 패스워드를 하나 만들어서 다같이 쓰면 어떨까? 실제로 많은 팀이 "공용 계정"이라는 형태로 이렇게 운영한다. 이 방식의 문제는 명확하다. 누군가 퇴사하면 마스터 패스워드를 바꾸고 남은 전원에게 새 패스워드를 다시 전파해야 한다. 전파하는 과정은 다시 슬랙 DM이다. 그리고 모두가 같은 열쇠를 쓰므로 감사 로그를 남겨도 "누가" 접근했는지 구분할 수 없다.

정리하면 공유 금고가 풀어야 할 문제는 다음 세 가지다.

  1. 각자 자기 마스터 패스워드만 기억한다. 남의 패스워드를 알 필요도, 공용 패스워드를 만들 필요도 없어야 한다.
  2. 서버는 비밀의 평문을 알 수 없다. 서버 운영자도, 서버를 해킹한 공격자도 마찬가지다.
  3. 멤버를 추가하고 제거할 수 있다. 새 멤버는 기존 비밀을 볼 수 있게 되고, 나간 멤버는 더 이상 볼 수 없어야 한다.

1번과 2번을 동시에 놓고 보면 꽤 곤란해 보인다. 각자 다른 패스워드로 같은 데이터를 복호화해야 하는데, 중간에서 재암호화를 해줄 서버는 평문을 만질 수 없다. 침착하게 접근하면 생각보다 간단한 답이 있는데, 그 전에 짚고 갈 것이 있다. 2번을 정말 지킬 것인지는 사실 선택의 문제다.

서버를 얼마나 믿을 것인가

글 서두에서 1Password와 AWS Secrets Manager를 나란히 언급했지만, 사실 두 제품은 서버에 대한 신뢰를 정반대로 설계했다. 공유 금고를 만들기 전에 반드시 정해야 하는 것이 바로 이 신뢰 모델이다.

AWS Secrets Manager는 서버를 신뢰하는 모델이다. 비밀을 저장하면 AWS가 관리하는 KMS(Key Management Service)의 키로 암호화된다.4 암호화 키가 AWS의 통제 아래에 있으므로, 원리적으로는 AWS가 마음만 먹으면 비밀을 복호화할 수 있다. 대신 AWS는 키를 HSM(Hardware Security Module)이라는, 키를 밖으로 꺼낼 수 없게 만든 전용 하드웨어 안에 가두고, 권한 관리 서비스(IAM)의 정책으로 접근을 통제하고, 모든 접근을 감사 기록 서비스(CloudTrail)에 남기는 것으로 신뢰를 보증한다. 공유도 간단하다. 애초에 복호화를 서버가 해주니, "이 사용자에게 이 비밀의 읽기 권한을 준다"는 정책 한 줄이면 끝난다.

반면 1Password는 서버를 신뢰하지 않는 모델이다. 모든 암호화와 복호화는 사용자의 디바이스에서 일어나고, 서버는 자신이 복호화할 수 없는 암호문 덩어리를 보관·전달만 한다. 서버 데이터베이스가 통째로 유출되어도 공격자는 암호문만 얻는다. 이런 설계를 제로 지식(Zero-Knowledge) 아키텍처라고 부른다.5 서버가 사용자의 비밀에 대해 아무런 지식이 없다는 뜻이다. 대신 공유가 어려워진다. 서버가 복호화를 못 하니 "권한 한 줄"로는 해결되지 않고, 이 글에서 다룰 암호학적 장치가 필요해진다.

어느 쪽이 좋은 걸까? 답은 용도에 따라 다르다. 서버 애플리케이션이 기동할 때 DB 비밀번호를 받아가는 인프라 시나리오라면 어차피 실행 주체가 클라우드 안에 있으므로 AWS를 신뢰하는 모델이 자연스럽고 운영도 편하다. 반면 사용자의 개인 비밀번호를 보관하는 패스워드 매니저가 "저희를 믿으세요"라고 말한다면 곤란하다. 회사가 해킹당하면? 직원이 들여다보면? 정부가 제출을 요구하면? 제로 지식 모델은 이 질문들에 "저희도 못 봅니다"라고 답할 수 있게 해준다.

서버 신뢰 모델제로 지식 모델
대표 제품AWS Secrets Manager, HashiCorp Vault1Password, Bitwarden
복호화 위치서버클라이언트 (디바이스)
서버 유출 시키 관리 체계가 뚫리면 평문 노출암호문만 노출
공유 방법접근 제어 정책 (IAM 등)암호학적 키 공유
패스워드 분실 시관리자가 초기화 가능원칙적으로 복구 불가

그런데 신뢰 모델이 정반대인 두 제품도 데이터를 암호화하는 구조 자체는 같은 패턴을 공유한다. 이 패턴이 공유 금고의 핵심 아이디어이므로 자세히 살펴보자.

금고 열쇠를 따로 포장하기

앞서 남겨둔 문제로 돌아가자. 각자 다른 마스터 패스워드를 가진 여러 명이, 평문을 모르는 서버를 사이에 두고, 같은 비밀을 복호화해야 한다.

발상의 전환은 이것이다. 비밀을 잠그는 열쇠와 사람을 확인하는 열쇠를 분리한다. 지금까지는 마스터 패스워드에서 유도한 키로 비밀을 직접 암호화했기 때문에 "비밀의 열쇠 = 나의 패스워드"라는 족쇄가 생겼다. 이 둘을 떼어놓으면 문제가 풀리기 시작한다.

구체적으로는 이렇게 한다. 금고를 만들 때마다 그 금고 전용의 랜덤한 대칭 키를 하나 생성한다. 이를 Vault Key(금고 키)라고 부르자. 금고 안의 모든 비밀은 이 Vault Key로 암호화한다. Vault Key는 사용자의 마스터 패스워드와 아무 관련이 없는, 그냥 난수로 만든 256비트 키다.

그러면 이제 문제가 "여러 명이 같은 비밀을 복호화한다"에서 **"여러 명에게 Vault Key를 안전하게 전달한다"**로 바뀐다. 금고 전체를 나눠주는 대신 금고 열쇠 하나만 나눠주면 되는 것이다. 그리고 열쇠 하나를 특정한 사람에게만 안전하게 전달하는 문제라면, 우리는 이미 답을 알고 있다. 전작에서 다룬 비대칭 암호화다. 전작에서 "비밀 키를 어떻게 나눠 갖는가"라는 키 배포 문제를 비대칭 암호화가 해결했다고 소개했는데, 그 문제가 여기서 정확히 같은 얼굴로 다시 등장한다.

각 사용자는 가입할 때 자신의 공개 키/개인 키 쌍을 만든다. 공개 키는 이름 그대로 공개되어 서버에 올라가고, 개인 키는 본인만 쓸 수 있게 보관한다(정확히 어떻게 보관하는지는 다음 장에서 다룬다). 공개 키로 암호화한 것은 짝이 되는 개인 키로만 복호화할 수 있으므로, Vault Key를 상대방의 공개 키로 암호화해서 건네면 서버를 경유하더라도 오직 상대방만 열어볼 수 있다.

Alice가 금고를 만들어 Bob에게 공유하는 전체 흐름을 따라가 보자.

  1. Alice와 Bob은 가입 시점에 각자 키 쌍을 만들고 공개 키를 서버에 등록한다.
  2. Alice가 금고를 만들면 Alice의 클라이언트가 랜덤 Vault Key를 생성하고, 비밀들을 이 키로 암호화해 서버에 올린다. 이때 Vault Key 자체는 Alice의 공개 키로 암호화해서 서버에 올린다. 나중에 다른 디바이스에서도 열 수 있어야 하기 때문이다.
  3. Alice가 Bob에게 금고를 공유하면, Alice의 클라이언트는 서버에서 Bob의 공개 키를 받아와 Vault Key를 Bob의 공개 키로 한 번 더 암호화한 사본을 만들어 서버에 올린다.
  4. Bob의 클라이언트는 서버에서 이 암호문을 받아 자신의 개인 키로 복호화하고, Vault Key를 얻어 금고 안의 비밀들을 복호화한다.

서버가 보관하는 것은 Vault Key로 암호화된 비밀들과, 멤버 수만큼 존재하는 "누군가의 공개 키로 암호화된 Vault Key"들이다. 서버는 어느 누구의 개인 키도 가지고 있지 않으므로 이 중 무엇도 복호화할 수 없다. 그저 암호문을 받아서 보관하고, 요청이 오면 전달할 뿐이다. 이렇게 콘텐츠를 잠근 대칭 키를 다른 키로 다시 암호화해서 콘텐츠 옆에 두는 기법을 키 래핑(Key Wrapping) 혹은 키 캡슐화라고 부른다.6

이 패턴은 공유 금고만의 전유물이 아니다. 앞서 언급한 AWS KMS의 봉투 암호화(Envelope Encryption)가 정확히 이 구조다. 데이터를 암호화하는 키(DEK, Data Encryption Key)와 그 키를 암호화하는 키(KEK, Key Encryption Key)를 계층으로 쌓는 것인데, Vault Key가 DEK, 각 멤버의 공개 키가 KEK에 해당한다. HTTPS도 비대칭 연산으로 세션용 대칭 키를 합의한 뒤7 실제 통신은 대칭 암호화로 하고, PGP 이메일도 본문은 랜덤 대칭 키로 암호화하고 그 키를 수신자의 공개 키로 감싼다는 점에서 전부 같은 조합이다.

왜 굳이 두 단계로 하는가

여기서 자연스러운 의문이 하나 나온다. 어차피 비대칭 암호화를 쓸 거라면 Vault Key라는 중간 단계 없이, 비밀 자체를 각 멤버의 공개 키로 암호화하면 되지 않을까?

먼저 산수가 다르다. 멤버가 $N$명이고 비밀이 $M$개인 금고를 생각해보자. 비밀을 공개 키로 직접 암호화하면 서버는 $N \times M$개의 암호문을 보관해야 하고, 비밀 하나를 수정할 때마다 $N$개의 암호문을 다시 만들어야 한다. 멤버 100명이 비밀 1,000개를 쓰는 조직이라면 암호문 10만 개다. Vault Key를 사이에 끼우면 암호문은 비밀 $M$개 + 감싼 열쇠 $N$개, 합쳐서 $N + M$개로 줄어든다. 비밀을 수정해도 Vault Key로 한 번만 다시 암호화하면 되고, 멤버를 추가해도 열쇠 하나만 더 감싸면 된다.

성능과 크기 제약도 있다. 비대칭 암호화는 대칭 암호화보다 수백 배에서 수천 배 느리고, 애초에 큰 데이터를 암호화할 수 없다. 예를 들어 2048비트 RSA는 한 번에 200바이트 남짓밖에 암호화하지 못한다.8 비밀번호 하나면 몰라도 SSH 키나 인증서 파일을 저장하려면 답이 없다. 반면 32바이트짜리 Vault Key는 한 번의 비대칭 연산으로 감쌀 수 있는 크기고, 실제 데이터는 빠르고 크기 제한 없는 대칭 암호화가 담당한다. 각 방식의 장점만 조합한 이런 구조를 하이브리드 암호화라고 부른다.

1Password는 실제로 어떻게 하는가

핵심 아이디어를 이해했으니 실제 제품은 이를 어떻게 조립했는지 살펴보자. 1Password는 자사의 보안 설계 전체를 백서로 공개하고 있는데9, 전작에서 다룬 케르크호프스의 원칙이 제품 차원에서 실천되는 모습이라 할 수 있다. 설계를 숨기는 것이 아니라 공개하고 검증받는 쪽이 안전하다는 것이다.

1Password의 키 구조를 위에서부터 따라 내려가면 다음과 같다.

맨 위에는 사용자가 기억하는 마스터 패스워드와, 가입 시 디바이스에서 생성되어 디바이스에 저장되는 Secret Key가 있다.10 이 둘을 PBKDF2 기반의 키 유도 과정에 함께 넣어 **AUK(Account Unlock Key)**라는 256비트 대칭 키를 만든다. 전작에서 마스터 패스워드 해시와 디바이스 랜덤 키를 XOR해서 마스터 언록 키를 만들었는데, 그것이 바로 이 구조를 단순화한 것이었다.

Secret Key가 왜 필요한지는 공격자의 입장에서 생각해보면 명확하다. 만약 AUK가 마스터 패스워드에서만 유도된다면, 서버 데이터베이스를 훔친 공격자는 자기 컴퓨터에서 느긋하게 패스워드 사전을 대입해볼 수 있다. 이런 공격을 오프라인 무차별 대입이라고 하는데, 서버의 로그인 시도 제한 같은 방어가 전혀 통하지 않는다. PBKDF2 같은 느린 키 유도 함수가 시도 속도를 수만 배 늦춰주기는 하지만, 그래도 인간이 지어낸 부실한 패스워드가 뚫리는 것은 시간문제다. 그런데 AUK를 만드는 재료에 128비트 난수인 Secret Key가 섞여 있다면, 공격자는 패스워드를 맞히는 것과 별개로 $2^128$이라는 경우의 수를 넘어야 한다. 사용자의 패스워드가 부실해도 서버 유출만으로는 뚫리지 않는 안전판인 셈이다. 대가도 있다. 마스터 패스워드와 Secret Key를 둘 다 잃어버리면 1Password 직원 누구도 계정을 복구해줄 수 없다.

다음 층으로 내려가자. 각 사용자는 가입할 때 공개 키/개인 키 쌍을 만든다. 공개 키는 서버에 그대로 올라가서 누구나 조회할 수 있고, 개인 키는 AUK로 암호화된 상태로 서버에 올라간다.11 개인 키를 서버에 올린다는 것이 이상하게 들릴 수 있는데, AUK로 잠겨 있으므로 서버 입장에서는 열 수 없는 상자일 뿐이다. 이렇게 하면 새 디바이스에서 로그인해도 마스터 패스워드와 Secret Key만 있으면 개인 키를 내려받아 복원할 수 있다.

그리고 마지막 층이 앞 장에서 다룬 Vault Key다. 금고마다 랜덤 생성되는 대칭 키로 금고 안의 아이템들을 암호화하고, Vault Key 자체는 각 멤버의 공개 키로 감싸져 서버에 저장된다. 결국 사용자가 마스터 패스워드를 입력하는 순간 AUK → 개인 키 → Vault Key → 아이템으로 이어지는 사슬이 위에서부터 차례로 풀리는 것이다.

이 구조에서 서버가 보관하는 것과 알 수 없는 것을 나눠보면 제로 지식이라는 말의 의미가 분명해진다.

그런데 마스터 패스워드가 서버로 전송되지 않는다면 로그인은 어떻게 하는 걸까? 일반적인 웹 서비스처럼 패스워드를 서버로 보내 해시를 비교한다면, 그 순간만큼은 서버가 패스워드를 만지게 된다. 1Password는 이를 피하기 위해 SRP(Secure Remote Password)라는 프로토콜을 쓴다. 양쪽이 패스워드에서 유도된 값으로 수학 문제를 주고받으며 "상대가 패스워드를 알고 있다"는 사실만 확인하는 방식으로, 패스워드도 그 해시도 네트워크를 타지 않는다.12 이 글의 구현에서는 범위를 줄이기 위해 인증은 생략할 것이다.

직접 만들어보기

이론은 충분하다. 이제 만들어보자. 전작과 같이 Python 3를 사용하며, 암호화 라이브러리로 pycryptodome을 쓴다.

$ pip install pycryptodome

만들 것은 서버 하나와 클라이언트 하나다. 물리적으로 다른 컴퓨터라는 경계를 분명히 하기 위해 실제로 HTTP 서버를 띄우고 클라이언트가 API를 호출하는 구조로 만든다. 다만 교육용 구현이므로 실제 제품이라면 반드시 필요할 것들을 몇 가지 생략한다. 로그인 인증과 API 권한 검사는 만들지 않고(SRP는 그것만으로 글 하나 분량이다), 통신은 TLS로 보호된다고 가정하며, 서버 저장소는 데이터베이스 대신 메모리 딕셔너리를 쓴다.

암호화 도구 상자

먼저 필요한 암호화 연산을 한 모듈에 모아두자. 개념은 전부 앞에서 나온 것들이다.

# crypto_utils.py

import base64
import os
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.Hash import SHA256
from Crypto.Protocol.KDF import PBKDF2
from Crypto.PublicKey import RSA


def b64(data: bytes) -> str:
    return base64.b64encode(data).decode('utf-8')

def unb64(data: str) -> bytes:
    return base64.b64decode(data)


# ── 대칭 암호화 (AES-256-GCM) ──

def generate_symmetric_key() -> bytes:
    return os.urandom(32)  # 256비트

def encrypt_symmetric(key: bytes, plaintext: bytes) -> dict:
    cipher = AES.new(key, AES.MODE_GCM)
    ciphertext, tag = cipher.encrypt_and_digest(plaintext)
    return {
        "ciphertext": b64(ciphertext),
        "nonce": b64(cipher.nonce),
        "tag": b64(tag),
    }

def decrypt_symmetric(key: bytes, data: dict) -> bytes:
    cipher = AES.new(key, AES.MODE_GCM, nonce=unb64(data["nonce"]))
    return cipher.decrypt_and_verify(unb64(data["ciphertext"]), unb64(data["tag"]))


# ── 키 유도 (PBKDF2) ──

def derive_auk(master_password: str, secret_key: bytes, email: str) -> bytes:
    salt = secret_key + email.encode()
    return PBKDF2(master_password, salt, dkLen=32, count=650000,
                  hmac_hash_module=SHA256)


# ── 비대칭 암호화 (RSA-OAEP) ──

def generate_key_pair() -> tuple:
    key = RSA.generate(2048)
    return key.export_key(), key.publickey().export_key()

def wrap_key(public_key_pem: bytes, key_to_wrap: bytes) -> str:
    rsa = PKCS1_OAEP.new(RSA.import_key(public_key_pem))
    return b64(rsa.encrypt(key_to_wrap))

def unwrap_key(private_key_pem: bytes, wrapped: str) -> bytes:
    rsa = PKCS1_OAEP.new(RSA.import_key(private_key_pem))
    return rsa.decrypt(unb64(wrapped))

전작과 달라진 점 몇 가지를 짚고 가자. 대칭 암호화 모드가 CBC에서 GCM으로 바뀌었다. GCM은 암호화와 동시에 인증 태그(tag)라는 일종의 위변조 검증 값을 만들어주는 모드로, 복호화 시 태그가 맞지 않으면 예외를 던진다. 누군가 서버에서 암호문을 조작하면 복호화 단계에서 바로 들통난다는 뜻이다. 요즘 새로 만드는 시스템이라면 GCM 같은 인증 암호화(AEAD)를 쓰는 것이 표준에 가깝다.13 반환 값에 함께 담기는 nonce는 전작에서 다룬 IV에 해당하는 값으로, 같은 평문을 암호화해도 매번 다른 암호문이 나오게 해준다.

키 유도 함수 derive_auk는 1Password의 AUK 유도를 단순화한 것이다. 솔트에 Secret Key와 이메일을 섞어서, 같은 마스터 패스워드를 쓰더라도 사용자마다 완전히 다른 AUK가 나오게 한다. 반복 횟수 65만 회는 1Password가 실제로 쓰는 값이다. 사소한 실패담을 하나 남기자면, 처음에는 이 65만이라는 숫자만 옮겨 적고 넘어갔다가 나중에야 pycryptodome의 PBKDF2 기본 해시가 오래된 HMAC-SHA1이라는 것을 발견했다. 1Password의 65만 회는 HMAC-SHA256 기준이라 hmac_hash_module을 명시해야 같은 조건이 된다. 라이브러리의 기본값은 하위 호환을 위해 오래된 것으로 남아 있는 경우가 많으니 암호화 관련 코드에서는 기본값을 의심하는 습관이 필요하다.

비대칭 암호화는 RSA에 OAEP 패딩을 사용한다.14 wrap_keyunwrap_key라는 이름에서 보이듯, 이 구현에서 비대칭 암호화가 하는 일은 오직 하나, 32바이트짜리 Vault Key를 감싸고 벗기는 것뿐이다.

서버: 열 수 없는 창고

다음은 서버다. 코드를 읽기 전에 미리 말해두자면, 이 서버 코드에는 복호화는커녕 암호화 코드조차 한 줄도 없다.

# server.py

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

# 실제라면 데이터베이스에 저장할 것이다
users = {}   # email -> { "public_key": ..., "encrypted_key_pair": ... }
vaults = {}  # vault_id -> { "items": { name: 암호문 }, "members": { email: 감싼 Vault Key } }


class VaultServer(BaseHTTPRequestHandler):
    def _json(self, code, data):
        body = json.dumps(data).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body)

    def _body(self):
        length = int(self.headers.get("Content-Length", 0))
        return json.loads(self.rfile.read(length))

    def do_POST(self):
        data = self._body()

        if self.path == "/users":  # 회원 가입
            users[data["email"]] = {
                "public_key": data["public_key"],
                "encrypted_key_pair": data["encrypted_key_pair"],
            }
            return self._json(201, {"ok": True})

        if self.path == "/vaults":  # 금고 생성
            vaults[data["vault_id"]] = {
                "items": {},
                "members": {data["email"]: data["wrapped_vault_key"]},
            }
            return self._json(201, {"ok": True})

        if self.path.startswith("/vaults/") and self.path.endswith("/items"):
            vault_id = self.path.split("/")[2]  # 아이템 추가
            vaults[vault_id]["items"][data["name"]] = data["encrypted_item"]
            return self._json(201, {"ok": True})

        if self.path.startswith("/vaults/") and self.path.endswith("/members"):
            vault_id = self.path.split("/")[2]  # 금고 공유
            vaults[vault_id]["members"][data["email"]] = data["wrapped_vault_key"]
            return self._json(201, {"ok": True})

        if self.path.startswith("/vaults/") and self.path.endswith("/rotate"):
            vault_id = self.path.split("/")[2]  # 키 로테이션 (뒤에서 다룬다)
            vaults[vault_id]["items"] = data["items"]
            vaults[vault_id]["members"] = data["members"]
            return self._json(200, {"ok": True})

        self._json(404, {"error": "not found"})

    def do_GET(self):
        if self.path.startswith("/users/"):  # 공개 키 조회
            email = self.path.split("/")[2]
            if email in users:
                return self._json(200, users[email])

        if self.path.startswith("/vaults/"):  # 금고 조회
            vault_id = self.path.split("/")[2]
            if vault_id in vaults:
                return self._json(200, vaults[vault_id])

        self._json(404, {"error": "not found"})

    def log_message(self, *args):
        pass  # 로그 생략


if __name__ == "__main__":
    print("Vault server listening on :8000")
    HTTPServer(("127.0.0.1", 8000), VaultServer).serve_forever()

하는 일이라고는 클라이언트가 올려주는 JSON을 받아서 딕셔너리에 넣고, 달라는 대로 꺼내주는 것이 전부다. 제로 지식 아키텍처에서 서버는 이 정도로 멍청해도 된다. 아니, 멍청해야 한다. 서버가 똑똑해지는 만큼(평문을 만지는 만큼) 신뢰 모델이 무너진다.

클라이언트: 모든 일이 벌어지는 곳

암호화의 주인공은 클라이언트다. 먼저 가입과 잠금 해제부터 보자.

# client.py

class VaultClient:
    def __init__(self, email: str, master_password: str):
        self.email = email
        self.master_password = master_password
        # Secret Key는 디바이스에 저장된다. 실제 제품이라면 Keychain에 넣을 것이다.
        self.secret_key_path = f".device_{email}.key"

    # ── 가입 ──
    def signup(self):
        # 1. 디바이스에서 Secret Key 생성
        secret_key = os.urandom(16)
        with open(self.secret_key_path, "wb") as f:
            f.write(secret_key)

        # 2. AUK 유도, 키 쌍 생성
        auk = derive_auk(self.master_password, secret_key, self.email)
        private_key, public_key = generate_key_pair()

        # 3. 개인 키는 AUK로 암호화해서 올린다. 공개 키는 그대로 올린다.
        request("POST", "/users", {
            "email": self.email,
            "public_key": public_key.decode(),
            "encrypted_key_pair": encrypt_symmetric(auk, private_key),
        })
        print(f"[{self.email}] 가입 완료. 개인 키는 암호화되어 서버에 저장됐다.")

    # ── 잠금 해제: 서버에서 암호화된 개인 키를 받아 AUK로 복호화 ──
    def unlock(self) -> bytes:
        with open(self.secret_key_path, "rb") as f:
            secret_key = f.read()
        auk = derive_auk(self.master_password, secret_key, self.email)
        me = request("GET", f"/users/{self.email}")
        return decrypt_symmetric(auk, me["encrypted_key_pair"])

requesturllib로 서버에 JSON을 주고받는 열 줄짜리 헬퍼 함수라 생략했다. 전체 코드는 글 끝에 링크해둔 저장소에 있다.

가입 과정이 곧 키 계층의 조립이다. 마스터 패스워드(사용자 기억)와 Secret Key(디바이스 저장)에서 AUK를 유도하고, 키 쌍을 만들고, 개인 키를 AUK로 잠가 공개 키와 함께 서버에 올린다. unlock은 그 역방향이다. 서버에서 암호화된 개인 키를 받아와 AUK로 연다. 마스터 패스워드가 틀리면 AUK가 다르게 유도되므로 GCM 태그 검증이 실패하며 예외가 터진다.

다음은 금고 생성과 아이템 저장, 조회다.

    # ── 금고 생성 ──
    def create_vault(self) -> str:
        vault_id = str(uuid.uuid4())[:8]
        vault_key = generate_symmetric_key()

        # Vault Key를 자신의 공개 키로 감싸서 올린다
        me = request("GET", f"/users/{self.email}")
        request("POST", "/vaults", {
            "vault_id": vault_id,
            "email": self.email,
            "wrapped_vault_key": wrap_key(me["public_key"].encode(), vault_key),
        })
        print(f"[{self.email}] 금고 {vault_id} 생성 완료")
        return vault_id

    # ── Vault Key 획득: 잠금 해제 → 자기 몫의 암호문을 개인 키로 벗긴다 ──
    def get_vault_key(self, vault_id: str) -> bytes:
        private_key = self.unlock()
        vault = request("GET", f"/vaults/{vault_id}")
        wrapped = vault["members"][self.email]
        return unwrap_key(private_key, wrapped)

    # ── 아이템 추가 ──
    def add_item(self, vault_id: str, name: str, secret: str):
        vault_key = self.get_vault_key(vault_id)
        request("POST", f"/vaults/{vault_id}/items", {
            "name": name,
            "encrypted_item": encrypt_symmetric(vault_key, secret.encode()),
        })
        print(f"[{self.email}] '{name}' 저장 완료")

    # ── 아이템 조회 ──
    def get_item(self, vault_id: str, name: str) -> str:
        vault_key = self.get_vault_key(vault_id)
        vault = request("GET", f"/vaults/{vault_id}")
        secret = decrypt_symmetric(vault_key, vault["items"][name])
        return secret.decode()

get_vault_key에 앞서 설명한 복호화 사슬이 그대로 들어있다. 마스터 패스워드 → AUK → 개인 키 → Vault Key 순서로 열쇠가 열쇠를 연다. 그리고 공유는, 그 긴 설명이 무색하게도 몇 줄이면 끝난다.

    # ── 금고 공유: 상대의 공개 키로 Vault Key를 감싸 서버에 올린다 ──
    def share_vault(self, vault_id: str, to_email: str):
        vault_key = self.get_vault_key(vault_id)
        other = request("GET", f"/users/{to_email}")
        request("POST", f"/vaults/{vault_id}/members", {
            "email": to_email,
            "wrapped_vault_key": wrap_key(other["public_key"].encode(), vault_key),
        })
        print(f"[{self.email}] 금고 {vault_id}{to_email}에게 공유 완료")

자신의 Vault Key를 꺼내서, 상대의 공개 키로 다시 감싸고, 서버에 올린다. 이것이 공유의 전부다. 비밀 데이터는 단 1바이트도 다시 암호화되지 않았다는 점에 주목하자. 금고에 비밀이 1만 개 있어도 공유의 비용은 동일하다.

실행해보기

시나리오를 돌려보자. Alice가 팀 금고를 만들어 운영 비밀들을 넣고 Bob에게 공유하는, 이 글의 서두에서 슬랙 DM이 담당하던 바로 그 상황이다.

# demo.py

alice = VaultClient("alice@example.com", "alice-master-password")
bob = VaultClient("bob@example.com", "bob-master-password")

alice.signup()
bob.signup()

vault_id = alice.create_vault()
alice.add_item(vault_id, "DB_PASSWORD", "prod-db-p@ssw0rd!")
alice.add_item(vault_id, "STRIPE_API_KEY", "sk_live_51H8...")

# 이 시점에 Bob은 금고를 열 수 없다
try:
    bob.get_item(vault_id, "DB_PASSWORD")
except KeyError:
    print(f"[bob] 금고 {vault_id}에 내 몫의 Vault Key가 없다 → 접근 불가")

alice.share_vault(vault_id, "bob@example.com")

# 이제 Bob은 자신의 마스터 패스워드만으로 비밀을 읽을 수 있다
print(f"[bob] DB_PASSWORD = {bob.get_item(vault_id, 'DB_PASSWORD')}")

실행 결과는 다음과 같다.

[alice@example.com] 가입 완료. 개인 키는 암호화되어 서버에 저장됐다.
[bob@example.com] 가입 완료. 개인 키는 암호화되어 서버에 저장됐다.
[alice@example.com] 금고 0c6fcc8d 생성 완료
[alice@example.com] 'DB_PASSWORD' 저장 완료
[alice@example.com] 'STRIPE_API_KEY' 저장 완료
[bob] 금고 0c6fcc8d에 내 몫의 Vault Key가 없다 → 접근 불가
[alice@example.com] 금고 0c6fcc8d를 bob@example.com에게 공유 완료
[bob] DB_PASSWORD = prod-db-p@ssw0rd!

Bob은 Alice의 마스터 패스워드를 모른다. Alice도 Bob의 마스터 패스워드를 모른다. 그런데 공유가 이뤄졌다. 그러면 서버는 무엇을 알고 있을까? 이 시점의 서버 저장소를 그대로 덤프해보면 이렇다.

{
  "items": {
    "DB_PASSWORD": {
      "ciphertext": "8gvI6+v6D71zC1rKpgRrLsA=",
      "nonce": "kr0EXlWeYU2UZd+JPqodsw==",
      "tag": "PSrQnQvvhhwyFx87qOLjsQ=="
    },
    "STRIPE_API_KEY": {
      "ciphertext": "cGzfA5gdaHn/lDsy05Nu",
      "nonce": "aEQkEA3LipMj9HE03FoH9g==",
      "tag": "NNNVglQo6h1d96JbEiPjBw=="
    }
  },
  "members": {
    "alice@example.com": "eggJtMTItGF4meC4KCQYQAtcS9sz...",
    "bob@example.com": "X1yaF55Pq7W8GuMp+I3lfZqOQ1Ki..."
  }
}

운영 DB 비밀번호가 저장되어 있지만 비밀 값의 평문은 서버 어디에도 없다. members에 든 것은 각자의 공개 키로 감싼 Vault Key이므로 서버는 이것도 열 수 없다. 이 데이터베이스가 통째로 유출되더라도 공격자가 얻는 것은 이 화면에 보이는 것이 전부다.

다만 이 화면에 보이는 것이 생각보다 많다. 아이템 이름과 멤버 이메일이 평문으로 노출되어 있는데, "이 팀은 Stripe를 쓰고 운영 DB 비밀번호가 여기 있으며 누가 접근할 수 있다"는 정보 자체도 공격자에게는 훌륭한 정찰 자료다. 그래서 실제 1Password는 아이템 제목이나 금고 이름 같은 메타데이터까지 Vault Key로 암호화한다. 이 구현에서는 구조를 드러내기 위해 이름을 평문으로 남겨뒀다.

떠난 사람은 열쇠를 기억한다

여기까지 만들고 나면 다 끝난 것 같지만, 공유 금고에서 가장 골치 아픈 문제가 남아 있다. 멤버 추가의 반대, 멤버 제거다.

서버 딕셔너리에서 Bob의 항목을 지우면 되지 않을까? members에서 Bob을 빼면 서버는 더 이상 Bob에게 감싼 Vault Key를 내주지 않는다. 하지만 이것으로 충분하다고 믿는 것은 위험하다. Bob은 어제까지 정당한 멤버였고, 정당한 멤버는 Vault Key를 복호화할 수 있었다. Bob이 그 키를 어딘가에 저장해뒀다면? 서버가 더 이상 내주지 않아도 Bob의 손에는 이미 키가 있다. 접근 권한은 회수할 수 있어도 기억은 회수할 수 없는 것이다.

그래서 제대로 된 멤버 제거는 자물쇠 교체 공사가 된다. 새 Vault Key를 만들고, 모든 아이템을 복호화해서 새 키로 다시 암호화하고, 남은 멤버 전원에게 새 키를 다시 감싸서 배포해야 한다. 이를 키 로테이션(Key Rotation)이라고 한다. 구현해보자.

    # ── 멤버 제외: 새 Vault Key로 전부 재암호화하고 남은 멤버에게 재배포 ──
    def revoke_member(self, vault_id: str, target_email: str):
        old_key = self.get_vault_key(vault_id)
        new_key = generate_symmetric_key()
        vault = request("GET", f"/vaults/{vault_id}")

        # 1. 모든 아이템을 복호화해서 새 키로 다시 암호화
        new_items = {}
        for name, encrypted in vault["items"].items():
            plaintext = decrypt_symmetric(old_key, encrypted)
            new_items[name] = encrypt_symmetric(new_key, plaintext)

        # 2. 제외 대상을 뺀 멤버들에게 새 키를 다시 감싸서 배포
        new_members = {}
        for email in vault["members"]:
            if email == target_email:
                continue
            member = request("GET", f"/users/{email}")
            new_members[email] = wrap_key(member["public_key"].encode(), new_key)

        request("POST", f"/vaults/{vault_id}/rotate", {
            "items": new_items,
            "members": new_members,
        })
        print(f"[{self.email}] {target_email} 제외 + 키 로테이션 완료 "
              f"(아이템 {len(new_items)}개 재암호화)")

이 재암호화 역시 클라이언트에서 일어난다는 점이 중요하다. 서버는 평문을 만질 수 없으므로 자물쇠 교체 공사도 열쇠를 가진 클라이언트만 할 수 있다. 정말 옛 열쇠가 무력화되는지 시험해보자. Bob이 팀을 떠나기 전에 Vault Key를 몰래 백업해뒀다는 악의적인 시나리오다.

# Bob이 떠나기 전에 Vault Key를 빼돌렸다
stolen_key = bob.get_vault_key(vault_id)

alice.revoke_member(vault_id, "bob@example.com")

# Bob이 훔쳐둔 옛 키로 복호화를 시도하면?
vault = request("GET", f"/vaults/{vault_id}")
try:
    decrypt_symmetric(stolen_key, vault["items"]["DB_PASSWORD"])
except ValueError as e:
    print(f"[bob] 옛 Vault Key로 복호화 실패: {e}")

print(f"[alice] DB_PASSWORD = {alice.get_item(vault_id, 'DB_PASSWORD')}")
[alice@example.com] bob@example.com 제외 + 키 로테이션 완료 (아이템 2개 재암호화)
[bob] 옛 Vault Key로 복호화 실패: MAC check failed
[alice] DB_PASSWORD = prod-db-p@ssw0rd!

Bob의 옛 열쇠는 더 이상 어떤 암호문도 열지 못하고, Alice는 아무 일 없었다는 듯 비밀을 읽는다. 로그의 MAC check failed는 앞서 다룬 GCM 인증 태그의 검증 실패를 뜻한다. 태그의 정식 이름이 MAC(Message Authentication Code, 메시지 인증 코드)이라서 라이브러리가 이렇게 보고하는 것이다.

다만 키 로테이션이 만능은 아니다. 키 로테이션은 미래를 지킬 뿐 과거는 지키지 못한다. Bob이 재직 중에 DB_PASSWORD의 평문 자체를 수첩에 적어뒀다면 어떤 암호학도 소용없다. 그래서 실무에서 퇴사자가 발생하면 금고의 키 로테이션과 별개로 비밀 값 자체를 교체(DB 비밀번호 변경, API 키 재발급)하는 것이 원칙이다. 1Password 백서도 이 한계를 피해 가지 않는다. 백서에 따르면 멤버를 제거해도 Vault Key를 즉시 로테이션하지는 않으며(모든 아이템 재암호화 비용이 크다), 서버가 해당 사용자에게 더 이상 데이터를 전달하지 않는 접근 제어에 의존한다. 그러면서 떠난 멤버가 키를 저장해뒀을 수 있으니 그 사람이 접근했던 비밀은 교체하라고 권고한다.

실제 제품이 더 하는 일들

이 글의 구현은 공유 금고의 뼈대다. 실제 제품들은 이 뼈대 위에 무엇을 더 얹는지 간단히 훑어보면, 남은 그림이 완성된다.

사실 이 글의 구현에는 조용히 서버를 믿고 있는 구멍이 하나 있다. Alice는 Bob에게 금고를 공유할 때 Bob의 공개 키를 서버에서 받아온다. 만약 악의적인 서버가 Bob의 공개 키 대신 자신이 만든 키 쌍의 공개 키를 내려준다면 어떻게 될까? Alice는 아무것도 모른 채 서버의 공개 키로 Vault Key를 감싸 올리고, 서버는 그것을 열어 금고를 통째로 복호화할 수 있다. 제로 지식이라 부르던 모델이 사실 키 배포 단계에서는 서버를 신뢰하고 있었던 것이다. 그래서 실제 제품들은 클라이언트가 한번 확인한 공개 키를 기억해두고 바뀌면 경고하거나, 사용자끼리 별도 채널로 공개 키의 지문(fingerprint)을 대조하는 장치를 둔다. 메신저 Signal의 "안전 번호 확인" 기능이 바로 이 문제를 다루는 대표적인 예다.

계정 복구도 큰 주제다. 제로 지식 모델의 약점은 마스터 패스워드를 잊으면 정말로 끝이라는 것인데, 조직용 1Password는 복구 그룹이라는 우아한 장치로 이를 보완한다. 원리는 이 글에서 배운 것의 재활용이다. 각 사용자의 키 재료를 복구 그룹의 공개 키로도 감싸서 저장해두면, 관리자가 (본인의 마스터 패스워드도, 사용자의 패스워드도 모른 채) 사용자의 계정을 다시 열어줄 수 있다.15 복구 권한 자체가 하나의 공유 금고인 셈이다.

금고 전체가 아니라 비밀번호 하나만 잠깐 건네고 싶을 때를 위한 아이템 단위 공유라는 것도 있다. 1Password의 공유 링크 기능은 아이템을 임의의 키로 암호화한 뒤 그 키를 URL 프래그먼트(# 뒤)에 실어 보낸다. URL 프래그먼트는 브라우저가 서버로 전송하지 않는 부분이라 여기서도 서버는 평문을 모른다. 만료 시간과 조회 횟수 제한을 걸 수 있는 것은 물론이다.

서두에서 팀 비밀 관리 도구의 약속 중 하나로 "누가 언제 접근했는지 기록이 남는다"를 꼽았는데, 이것도 여기서 갚아야 할 빚이다. 서버는 평문을 몰라도 누가 언제 어느 금고의 암호문을 요청했는지는 안다. 제로 지식 모델의 감사 로그는 이 접근 메타데이터로 만들어진다. 비밀의 내용은 암호학이 지키고, 접근 기록은 서버가 남긴다. 퇴사자가 발생했을 때 그 사람이 접근했던 금고 목록을 뽑아 교체할 비밀을 정하는 일도 이 기록이 있어야 가능하다.

인프라 쪽 제품은 다른 방향으로 진화한다. AWS Secrets Manager의 대표 기능은 자동 로테이션으로, 코드를 대신 실행해주는 AWS의 Lambda 함수를 연결해두면 예를 들어 30일마다 DB 비밀번호를 자동으로 새로 발급해서 교체해준다. 앞서 퇴사자가 나가면 비밀 값 자체를 교체하라고 했는데, 애초에 비밀이 주기적으로 알아서 바뀌면 유출된 비밀의 유효기간도 짧아진다. 이 밖에도 세밀한 권한(읽기 전용 멤버, 아이템별 접근 제어), 디바이스 등록 관리 같은 것들이 실제 제품과 장난감 구현의 간극을 채운다.

마치며

전작에서 남겨둔 숙제였던 공유 금고를 드디어 다뤘다. 돌아보면 새로 등장한 암호 기술은 하나도 없다. 대칭 암호화, 비대칭 암호화, 키 유도까지 전부 전작에서 소개한 재료 그대로다. 달라진 것은 조합이다. 비밀을 잠그는 열쇠(Vault Key)와 사람을 확인하는 열쇠(키 쌍)를 분리하고, 열쇠로 열쇠를 감싸는 계층을 쌓는 것만으로 "서버도 모르고 서로의 패스워드도 모르는 공유"라는 얼핏 모순 같은 요구가 풀렸다. 암호학의 재미는 새 알고리즘이 아니라 이런 프로토콜 설계에 있는 것 같다.

이 글의 코드는 어디까지나 원리 이해를 위한 것으로, 검증된 것이 아니다. 인증도 권한 검사도 없고, 실제 제품이라면 고려해야 할 수많은 공격 지점(클라이언트 변조, 메모리 덤프, 메타데이터 노출 등)을 다루지 않았다. 실무에서 필요하다면 직접 만들기보다 검증된 제품을 쓰기를 권한다. 다만 그 제품이 안에서 무엇을 하고 있는지 알고 쓰는 것과 모르고 쓰는 것의 차이는 크다. 이 글이 그 차이를 만드는 데 도움이 되었기를 바란다.

전체 코드는 GitHub 저장소에서 확인할 수 있다.

Footnotes

  1. 암호학에서 비밀 공유(Secret Sharing)라고 하면 하나의 비밀을 여러 조각으로 나눠 일정 개수 이상이 모여야 복원되도록 하는 샤미르 비밀 분산 같은 기법을 가리키기도 한다. 이 글의 주제는 그쪽이 아니라 여러 사람이 함께 쓰는 금고다.

  2. 공격자는 사회공학으로 협력업체 직원의 VPN 계정을 얻은 뒤, 내부 공유 폴더에서 PAM(권한 접근 관리) 시스템의 관리자 자격 증명이 하드코딩된 스크립트를 발견했다. 이 사고로 Uber의 AWS, Google Workspace, 내부 슬랙까지 광범위하게 뚫렸다.

  3. PBKDF2는 해시를 수십만 번 반복하여 무차별 대입 공격의 속도를 늦추는 키 유도 함수다. 전작에서 솔트, 키 스트레칭과 함께 다뤘다.

  4. 정확히는 봉투 암호화 구조로, 비밀마다 개별 데이터 키를 생성해 암호화하고 그 데이터 키를 KMS의 키로 다시 암호화해서 함께 저장한다. 이 패턴은 뒤에서 다시 등장한다.

  5. 암호학의 증명 기법인 영지식 증명(Zero-Knowledge Proof)과는 다른 개념이다. 여기서는 "서버가 고객 데이터에 대한 지식을 갖지 않는 설계"라는 아키텍처 원칙을 가리키는 마케팅에 가까운 용어로 쓰인다.

  6. 엄밀히는 정해진 키를 다른 키로 암호화하면 키 래핑, 암호화 과정에서 키가 함께 생성되면 키 캡슐화(KEM)로 구분하기도 하지만, 실무에서는 느슨하게 섞어 부른다.

  7. 예전 TLS는 클라이언트가 만든 세션 키를 서버의 공개 키로 암호화해 보냈지만, 현대의 TLS 1.3은 디피-헬먼 키 합의(비밀을 직접 주고받지 않고도 양쪽이 같은 키를 만들어내는 방법)로 세션 키를 함께 만들어낸다. 어느 쪽이든 비대칭 연산으로 대칭 키를 마련한다는 뼈대는 같다.

  8. RSA는 키 길이보다 큰 데이터를 암호화할 수 없고, OAEP 패딩이 차지하는 몫을 빼면 2048비트(256바이트) 키 기준 실제 가용량은 200바이트 남짓이다.

  9. 1Password Security Design White Paper. 이 글에서 다루는 키 계층 구조의 원전이며, 실제 제품이 마주하는 온갖 엣지 케이스에 대한 논의가 담겨 있어 일독할 가치가 있다.

  10. 1Password 앱에서 A3-로 시작하는 34자리 코드를 본 적이 있다면 그것이 Secret Key다. 계정 생성 시 디바이스에서 랜덤 생성되며 서버로 전송되지 않는다.

  11. 정확히는 AUK가 키셋의 대칭 키를 암호화하고 그 대칭 키가 개인 키를 암호화하는 중간 계층이 하나 더 있다. 이 글에서는 한 단계로 단순화했다.

  12. SRP는 PAKE(Password-Authenticated Key Exchange)라는 프로토콜 계열에 속한다. 인증에 성공하면 부산물로 세션 암호화 키까지 얻는다는 덤도 있다.

  13. 전작에서 쓴 CBC 모드는 위변조 검증이 없어서 암호문을 조작하는 공격에 노출될 수 있다. 복호화 실패 메시지를 단서 삼아 평문을 복원해내는 패딩 오라클 공격이 대표적이다. AEAD(Authenticated Encryption with Associated Data)는 기밀성과 무결성을 한 번에 보장한다.

  14. OAEP는 RSA에 랜덤성을 더하는 패딩 방식이다. 참고로 실제 1Password의 개인 키셋도 RSA-OAEP 2048비트를 사용하므로, 이 글의 구현과 같은 선택이다.

  15. 복구 후에는 사용자가 새 마스터 패스워드와 Secret Key를 설정하고 키 재료를 다시 감싼다. 물론 복구 그룹이 존재한다는 것 자체가 공격 표면이 하나 늘어난다는 뜻이므로, 백서는 이를 보안과 가용성의 트레이드오프로 설명한다.