엔지니어링 노트보안과 감사

DB 백업에 따라가는 비밀번호, 봉투 암호화로 키를 따로 둔다

OwlAI의 메타데이터 DB에는 고객 DB 비밀번호, LLM 프로바이더의 API 키, 커넥터(MCP 서버처럼 회사가 OwlAI에 연결한 외부 서비스)의 OAuth 토큰, DB 연결에 쓰는 SSH 개인 키가 들어 있습니다. 이 값을 DB 칸에 그대로 두면 덤프나 백업 파일 하나를 가져간 사람이 전부 읽습니다. 백업 파일은 운영 DB와 다른 곳으로 복사되고, 운영 DB보다 오래 보관됩니다.

그래서 OwlAI는 자격 증명을 봉투 암호화(envelope encryption)로 저장합니다. 자격 증명을 데이터 키로 암호화하고, 그 데이터 키를 DB 밖에 있는 키로 다시 암호화합니다. DB만 가진 사람은 아무것도 풀지 못합니다. 이 글은 이 방식이 어디까지 막는지, 키를 바꾸거나 잃을 때와 평문이 남았을 때 어떻게 하는지를 다룹니다. 비밀이 모델과 로그로 흘러가는 길을 막는 쪽은 「토큰이 든 2,000자 다운로드 주소, 모델에게는 19자 참조만 보인다」에서 다뤘습니다.

DB 암호화나 앱 키 하나로는 덤프를 막지 못하거나 키를 바꾸기 어렵다

흔한 방법은 둘입니다. 첫째는 DB의 저장소 암호화(TDE·디스크 암호화)입니다. 디스크나 데이터 파일을 통째로 가져간 사람은 이것으로 막힙니다. 하지만 DB에 접속할 수 있는 사람이 덤프를 뜨면 값은 평문으로 나오고, 논리 덤프로 만든 백업 파일에도 평문이 그대로 들어갑니다.

둘째는 애플리케이션이 키 하나로 비밀 칸을 암호화하는 방법입니다. 덤프는 막지만 세 가지가 남습니다.

  • 키를 바꾸려면 모든 행을 풀어서 새 키로 다시 암호화해야 합니다.
  • 키를 로그인 서명에 쓰는 설치 비밀에서 파생하면, 그 비밀을 바꾸는 순간 저장된 자격 증명이 전부 풀리지 않게 됩니다.
  • 암호문을 다른 행이나 다른 조직의 칸으로 복사해도 같은 키로 풀립니다. DB를 고칠 수 있는 사람은 남의 DB 비밀번호를 자기 연결에 옮겨 붙여 쓸 수 있습니다.

봉투 암호화로 저장하면 키를 바꿀 때 감싼 데이터 키만 다시 암호화하면 되고, 암호문을 저장된 자리에 묶을 수 있습니다.

자격 증명은 데이터 키로 암호화하고, 데이터 키는 DB 밖의 키로 감싼다

자격 증명 본문은 데이터 키(DEK)로 AES-256-GCM 암호화합니다. 데이터 키는 키 암호화 키(KEK)로 감싸서 암호문 옆에 함께 저장합니다. 저장값은 JSON 한 덩어리이고, 어느 KEK로 감쌌는지 나타내는 키 ID, 감싼 데이터 키, nonce, 암호문이 들어갑니다. KEK는 DB에 없습니다.

자격 증명은 데이터 키로, 데이터 키는 DB 밖의 KEK로 감싸 저장한다
그림 1. 자격 증명은 데이터 키로, 데이터 키는 DB 밖의 KEK로 감싸 저장한다

이렇게 저장하는 값은 고객 DB 비밀번호, LLM 프로바이더의 공용·개인 API 키, 커넥터의 토큰·API 키·클라이언트 비밀, 조직의 SSH 개인 키, 계정의 MFA 비밀, 로그인 정보를 전송할 때 쓰는 개인 키입니다. OAuth 인증 중간값(10분)이나 모델에게 가려 보여 준 다운로드 주소(1시간, 「토큰이 든 2,000자 다운로드 주소, 모델에게는 19자 참조만 보인다」)처럼 짧게 사는 값도 같은 방식으로 저장합니다.

쓰기는 언제나 이 저장 형식입니다. KEK 설정이 없으면 자격 증명을 저장하지 않습니다. 읽기는 이 저장 형식의 값만 풉니다. 자격 증명 칸에 평문이 들어 있으면 그 값을 자격 증명으로 쓰지 않고 빈 값으로 봅니다. 평문을 그대로 읽어 주면, DB를 고칠 수 있는 사람이 암호화된 값을 자기가 아는 평문으로 바꿔 넣어 OwlAI가 그 값으로 접속하게 만들 수 있기 때문입니다.

암호문은 저장된 칸에서만 풀린다

AES-GCM은 암호문과 함께 추가 인증 데이터(AAD)를 검증합니다. 본문을 암호화할 때 이 자리에 용도(어느 테이블의 어느 칸인지), 조직, 행 식별자를 넣습니다. 그래서 암호문을 다른 행, 다른 조직, 다른 용도의 칸으로 복사하면 인증 태그가 맞지 않아 풀리지 않습니다. 앞에서 본 「남의 DB 비밀번호를 자기 연결에 옮겨 붙이기」가 여기서 막힙니다.

데이터 키를 감쌀 때 함께 묶는 정보에는 용도와 조직만 넣고 행 식별자는 넣지 않습니다. 행까지 넣으면 행마다 데이터 키를 따로 감싸야 해서, KMS를 쓰는 OwlAI SaaS에서는 쓰기마다 KMS를 한 번씩 더 불러야 합니다. 용도와 조직만 묶으면 같은 조직·용도의 쓰기가 5분 동안 감싼 데이터 키 하나를 나눠 씁니다. 행 단위 묶음은 본문의 추가 인증 데이터가 이미 맡고 있으므로 복사는 그대로 막힙니다.

구축형은 KEK를 설치 비밀로, OwlAI SaaS는 KMS에 둔다

KEK를 두는 곳은 설치 형태마다 다르고, 두 방식은 같은 인터페이스를 따릅니다.

설치 형태 KEK가 있는 곳
구축형 설치 비밀에 넣은 32바이트 키. 값 대신 파일 경로를 주면, 비밀 관리 도구가 실행 시점에 넣어 주는 파일에서 읽어 설정 파일에 키가 남지 않습니다
OwlAI SaaS 클라우드 KMS 키. 앱이 데이터 키를 감싸고 풀 때만 KMS를 부르고, 용도와 조직은 KMS의 암호화 컨텍스트로 실려 KMS가 대조합니다

KMS 호출은 암호화 컨텍스트와 함께 CloudTrail에 남습니다. 행 식별자는 이 컨텍스트에 넣지 않으므로 클라우드 감사 로그에도 남지 않습니다. OwlAI SaaS 호스트는 KMS 방식으로만 뜹니다. 설정 실수로 파일 키를 들고 뜨려 하면 기동을 거부합니다.

구축형 KEK는 로그인 서명에 쓰는 설치 비밀과 다른 값이어서, 로그인 비밀을 바꿔도 저장된 자격 증명은 그대로 풀립니다. KEK 설정이 없거나 형식이 틀리면 backend는 기동을 거부하고, 설치 전 점검도 같은 조건을 봅니다.

위협별로 막는 범위는 다음과 같습니다.

위협 구축형 OwlAI SaaS
DB만 유출(덤프, 백업, 읽기 권한 탈취) KEK 없이 풀 수 없습니다 KEK 없이 풀 수 없습니다
호스트 root 탈취(설치 비밀과 DB를 함께 쥠) 설치 비밀에 둔 KEK로는 막지 못합니다. 이 수준이 필요하면 비밀 관리 도구의 파일 주입이나 외부 KMS를 씁니다 KEK 원문은 호스트에 없습니다. 탈취한 동안에는 호스트의 역할로 KMS를 부를 수 있지만, 그 호출은 감사 로그에 남고 역할이나 키 정책을 거두면 막힙니다

키를 바꿀 때는 감싼 데이터 키만 다시 감싼다

읽기는 지금 쓰는 KEK와 옛 KEK를 모두 들고 합니다. 그래서 키를 바꾸는 도중에도 옛 KEK로 감싼 행을 읽을 수 있습니다. 목록에 없는 키 ID로 감싼 행은 풀지 않습니다. DB를 고친 사람이 앱이 쓸 수 있는 다른 KMS 키를 가리키도록 키 ID를 바꿔 넣는 경로를 막기 위해서입니다.

키 교체는 네 단계입니다.

  1. 새 KEK를 읽기용 목록에 더합니다.
  2. 쓰기용 KEK를 새 키로 바꾸고 backend와 worker를 재시작합니다. 이때부터 새로 저장하는 값은 새 키로 감쌉니다.
  3. 워커의 주기 작업이 한 시간마다 옛 KEK로 감싼 데이터 키를 풀어 새 KEK로 다시 감쌉니다. 본문은 다시 암호화하지 않습니다. 기다리지 않으려면 같은 일을 바로 한 번 돌리는 명령을 씁니다. 다시 돌려도 남은 행만 옮깁니다.
  4. 다음 주기에 옮긴 수를 확인하고, 그다음 주기에 옮길 행이 0인 것을 본 뒤에 옛 KEK를 지웁니다. 옮기지 못한 행이 남았는데 옛 키를 먼저 지우면 그 행은 풀리지 않습니다.
키 교체 전후의 저장값: 키 ID와 감싼 데이터 키만 바뀐다
그림 2. 키 교체 전후의 저장값: 키 ID와 감싼 데이터 키만 바뀐다

주기 작업은 데이터 키를 다시 감싸기 전에 본문의 인증 태그부터 확인합니다. 다른 행에서 복사해 온 암호문을 새 키로 다시 감싸 주면, 풀리지 않아야 할 값이 정상 저장값처럼 보이게 되기 때문입니다.

다시 감싸기는 데이터 키를 바꾸지 않습니다. KEK가 유출돼서 바꾸는 경우에는 이 점이 중요합니다. 옛 KEK와 옛 DB 덤프를 함께 가진 사람은 이미 데이터 키를 알고 있고, 본문이 그대로인 행은 새 덤프에서도 풀 수 있습니다. 그래서 KEK가 유출됐다면 키 교체와 함께 저장된 자격 증명 자체를 바꿉니다. 커넥터는 다시 연결하고, API 키와 DB 비밀번호는 발급한 곳에서 바꾼 뒤 다시 등록합니다. 키 교체만으로 충분한 경우는 정기 교체뿐입니다.

키는 DB 백업과 다른 곳에 보관한다

KEK를 잃으면 저장된 자격 증명은 되살릴 수 없고, 관리자가 모두 다시 등록해야 합니다.

구축형의 백업 도구는 DB와 파일 저장소를 한 시점의 묶음으로 남기고, KEK가 든 설치 비밀은 담지 않습니다. 설치 비밀은 데이터 백업과 다른 곳, 접근 권한이 분리된 보관소나 비밀 관리 도구에 둡니다. DB 백업과 KEK가 한곳에 있으면 그 백업 하나가 새는 순간 봉투 암호화는 아무것도 막지 못합니다. KEK를 바꿨다면 옛 키도 함께 보관합니다. 옛 키로 감싼 행이 예전 데이터 백업에 남아 있기 때문입니다.

OwlAI SaaS의 KMS 키는 지우지 않고, 쓰지 않게 되면 비활성화한 채 삭제 대기 기간을 최대로 둡니다. 키 정책은 키 관리자와 키 사용자를 나눠, 관리자는 키로 풀 수 없고 앱의 역할은 키를 관리할 수 없습니다.

암호화되지 않은 자격 증명은 점검 명령이 센다

봉투 암호화는 평문 자격 증명이 DB에 남아 있지 않을 때만 의미가 있습니다. 이것을 확인하는 점검 명령이 있습니다. backend 컨테이너 안에서 KEK 없이 돌고, 값은 출력하지 않습니다. 다음을 칸마다 세어 냅니다.

모두 0이면 성공, 남은 행이 있으면 실패, 설정이나 DB 오류는 따로 정한 종료 코드로 끝납니다. 조직 테이블에는 행 수준 보안(RLS)이 걸려 있어서 명령은 조직마다 그 조직의 세션을 열어 셉니다. 판을 올리기 전에 이 명령으로 0을 확인합니다. 옛 형식 암호문은 새 판이 풀지 못하므로, 남아 있으면 그 자격 증명을 다시 등록합니다.

주기 작업은 자격 증명 칸의 평문을 암호화하지 않습니다. 자격 증명 칸의 평문은 DB를 고친 사람이 심은 값일 수 있고, 주기 작업이 그 값을 암호화해 주면 앞에서 본 평문 거절이 한 시간짜리 지연으로 바뀝니다. 작업은 그런 행을 세어 로그로 남기고, 점검 명령이 그 수를 보고합니다. 정말 옮겨야 할 옛 평문이라면 출처를 확인한 사람이 평문까지 암호화하는 옵션으로 한 번만 돌립니다.

새 비밀 칸이 생겨도 점검에서 빠지지 않게, 이 저장 형식으로 값을 쓰는 코드의 목록과 점검이 훑는 칸의 목록이 같은지를 테스트가 확인합니다. 새 칸을 만들고 점검 목록에 올리지 않으면 머지 전에 실패합니다.

설계에서 고른 것

구축형의 기본 KEK는 설치 비밀에 둡니다. 외부 KMS를 기본으로 하면 KEK 원문이 호스트에서 빠지지만, 고객 호스트 한 대에서 다른 장비 없이 바로 서야 하는 구축형에는 맞지 않습니다. 그래서 DB 유출을 기본 방어 범위로 잡고, 호스트 root 수준까지 막아야 하는 설치는 비밀 관리 도구의 파일 주입이나 외부 KMS를 쓰게 했습니다. 두 방식이 같은 인터페이스를 따르므로 설정만 바꾸면 됩니다.

KMS를 쓸 때 푼 데이터 키를 5분 동안 캐시합니다. 읽기마다 KMS를 부르면 권한 회수가 바로 반영되지만, 지연과 KMS 비용이 자격 증명을 읽는 횟수만큼 늘어납니다. 그 대신 권한 회수가 최대 5분 늦게 반영되고, 그 5분 동안의 호출도 감사 로그에 남습니다.

키 교체는 데이터 키를 다시 감싸는 데서 끝내고, KEK 유출 대응은 자격 증명 재발급을 짝으로 붙인 별도 절차로 둡니다. 키 교체가 데이터 키까지 바꾸면 유출 대응이 한 번에 끝나지만, 정기 교체 때마다 모든 본문을 다시 암호화해야 합니다. 정기 교체는 자주, 유출 대응은 드물게 일어나므로 자주 하는 쪽을 가볍게 했습니다.

KEK를 잃었을 때 되살리는 경로 대신 다시 등록하는 절차를 골랐습니다. 되살리는 경로가 있으면 그 경로가 KEK 없이 자격 증명을 푸는 또 하나의 길이 됩니다. 그래서 키를 DB 백업과 다른 곳에 보관하는 절차를 두고, 암호화 범위를 자격 증명 칸으로 한정해 키를 잃어도 다른 데이터는 남게 했습니다.

고객 호스트 한 대에 설치해 운영한다면, KEK를 어디에 두고 누가 보관할지, DB 백업과 어떻게 떼어 놓을지를 설치할 때 함께 정해 두어야 합니다.

어떤 문제를 해결하나요?

보안과 감사의 다른 글