본문으로 건너뛰기

암호화

App은 민감 정보를 평문으로 저장하지 않고, Envelope Encryption 방식으로 저장합니다.

이 문서는 아래를 빠르게 이해하기 위한 문서입니다.

  • KEK, DEK가 무엇인지
  • 저장 포맷이 어떻게 생겼는지
  • provider-id, wrapped-dek, iv, ciphertext가 각각 무엇인지
  • rotation 이후 rewrap()이 무엇을 바꾸는지

KEK와 DEK

  • DEK실제 데이터 본문을 암호화하는 키입니다.
  • KEKDEK를 감싸는 상위 키입니다.
  • 저장할 때는 plaintext를 바로 KEK로 암호화하지 않습니다.
  • 먼저 plaintext를 DEK로 암호화하고, 그 DEK를 KEK로 다시 감싸서 함께 저장합니다.

저장 포맷

v1.{provider-id}.{key-ref}.{wrapped-dek}.{iv}.{ciphertext}

이 값은 단순 암호문이 아니라, 어떤 provider와 어떤 key version으로 복호화해야 하는지까지 포함한 envelope입니다.

provider별 예시

ENV

로컬 DB에서 실제로 확인한 예시:

v1.ENV.a2VrLXYx.WVEzNjFZS0xnR2RRY2NrUm85dGY4UXE3Y3VkR2c3STNYdkdjV2NBcnhBVHphd1M5b2pzdmY2dktxS2tOVHorYTNSeDNkZHFWcmlQQnp5TVY.NygkTW7d5YqxOvRQ.0nhY/1VsGdGHQTbjSbOyIkoBu95f0CPYELRO4JBcg6ju956FBrpWx8du+8Stf89F
  • provider-id: ENV
  • key-ref: a2VrLXYxkek-v1 (key-ref는 운영/디버깅용 참고 정보)
  • wrapped-dek: ENV KEK로 감싼 DEK

VAULT_TRANSIT

현재 구현/테스트 형식 기준 예시:

v1.VAULT_TRANSIT.djc.dmF1bHQ6djc6ZW5jcnlwdGVkLWNpcGhlcnRleHQ.NygkTW7d5YqxOvRQ.0nhY/1VsGdGHQTbjSbOyIkoBu95f0CPYELRO4JBcg6ju956FBrpWx8du+8Stf89F
  • provider-id: VAULT_TRANSIT
  • key-ref: djcv7 (key-ref는 운영/디버깅용 참고 정보)
  • wrapped-dek: dmF1bHQ6djc6ZW5jcnlwdGVkLWNpcGhlcnRleHQvault:v7:encrypted-ciphertext

즉 provider가 바뀌면 envelope 전체 구조가 바뀌는 것이 아니라, 같은 v1 포맷 안에서 provider-id, key-ref, wrapped-dek 해석 방식만 달라집니다.

인코딩 규칙

이 포맷은 .로 이어 붙인 문자열이지만, 각 파트가 모두 같은 인코딩을 쓰지는 않습니다.

파트인코딩
v1리터럴 문자열
provider-id리터럴 문자열
key-refbase64url without padding
wrapped-dekbase64url without padding
iv일반 Base64
ciphertext일반 Base64

이렇게 나눈 이유:

  • key-ref, wrapped-dek는 provider가 만든 문자열 metadata입니다.
  • envelope는 .를 구분자로 쓰기 때문에, metadata 쪽은 .와 충돌하지 않는 base64url로 한 번 감싸서 저장합니다.
  • iv, ciphertext는 이 구현 안에서 생성한 순수 바이트 배열이라 일반 Base64로 충분합니다.

중요한 점은 . 구분자가 깨지지 않는가입니다.

  • base64url.를 쓰지 않습니다.
  • 일반 Base64.를 쓰지 않습니다.

즉 인코딩이 섞여 있어도 split 경계는 안정적으로 유지됩니다.

필드별 의미

필드의미
v1암호문 포맷 버전
provider-id이 암호문을 만든 KEK provider
key-ref사용한 KEK 버전 (운영/디버깅용)
wrapped-dekKEK로 감싼 DEK
ivpayload 암호화에 사용한 초기화 값
ciphertext실제 데이터 본문 암호문

provider-id

provider-id는 이 암호문을 만든 KEK provider를 나타냅니다.

현재 구현 기준 provider는 아래 두 가지입니다.

  • ENV
  • VAULT_TRANSIT

이 값이 중요한 이유는, 복호화할 때 현재 provider가 아니라 ciphertext에 기록된 provider를 따라가기 때문입니다.

즉 현재 시스템이 VAULT_TRANSIT 모드로 돌고 있어도, 예전에 ENV로 저장된 ciphertext는 계속 읽을 수 있어야 합니다.

Provider 설정

현재 provider 선택과 provider 등록은 분리되어 있습니다.

  • app.crypto.current-provider: 신규 encrypt/rewrap에 사용할 provider
  • app.crypto.providers.env: ENV provider 설정
  • app.crypto.providers.vault-transit: Vault Transit provider 설정

중요한 점:

  • 현재 provider는 하나만 선택합니다.
  • 실제로 등록되는 provider는 providers.* 아래에서 값이 채워진 섹션만 해당합니다.
  • 따라서 vault-transit만 설정하면 Vault only 모드가 되고,
  • envvault-transit을 둘 다 설정하면 migration용 dual-provider 모드가 됩니다.

application.yaml 사용법

application.yaml 구조는 그대로 두고, 실제 값은 Spring env binding으로 넣을 수 있습니다.

예:

  • app.crypto.current-providerAPP_CRYPTO_CURRENT_PROVIDER
  • app.crypto.providers.env.kekAPP_CRYPTO_PROVIDERS_ENV_KEK
  • app.crypto.providers.vault-transit.key-nameAPP_CRYPTO_PROVIDERS_VAULT_TRANSIT_KEY_NAME

즉 규칙은:

  • ._
  • -_
  • 전부 대문자

Vault only

app:
crypto:
current-provider: VAULT_TRANSIT

providers:
vault-transit:
base-url: http://127.0.0.1:8216
token: ${APP_CRYPTO_PROVIDERS_VAULT_TRANSIT_TOKEN}
mount-path: transit
key-name: deck-app

의미:

  • 신규 encrypt/rewrap은 VAULT_TRANSIT
  • 등록 provider도 VAULT_TRANSIT 하나뿐
  • 기존 ENV ciphertext를 읽을 필요가 없을 때 사용하는 가장 단순한 구조

ENV only

app:
crypto:
current-provider: ENV

providers:
env:
key-versions:
v1: ${APP_CRYPTO_PROVIDERS_ENV_KEY_VERSIONS_V1}
v2: ${APP_CRYPTO_PROVIDERS_ENV_KEY_VERSIONS_V2}
active-key-ref: v2

의미:

  • 신규 encrypt/rewrap은 ENV
  • 기존 ENV v1도 계속 읽을 수 있음
  • 새 저장은 ENV v2로 나감

참고:

  • 단일키만 쓸 때는 APP_CRYPTO_PROVIDERS_ENV_KEK, APP_CRYPTO_PROVIDERS_ENV_KEK_KID만으로도 충분합니다.
  • 키 회전까지 운영하려면 key-versions + active-key-ref 구조가 더 맞습니다.

혼용: ENV current + Vault fallback

app:
crypto:
current-provider: ENV

providers:
env:
key-versions:
v1: ${APP_CRYPTO_PROVIDERS_ENV_KEY_VERSIONS_V1}
v2: ${APP_CRYPTO_PROVIDERS_ENV_KEY_VERSIONS_V2}
active-key-ref: v1

vault-transit:
base-url: http://127.0.0.1:8216
token: ${APP_CRYPTO_PROVIDERS_VAULT_TRANSIT_TOKEN}
mount-path: transit
key-name: deck-app

의미:

  • 기존 VAULT_TRANSIT ciphertext는 계속 읽음
  • 신규 저장은 ENV v1
  • 이후 active-key-ref: v2로 바꾸면 신규 저장만 ENV v2

Vault key-name

Vault Transit에서는 앱이 key version을 직접 고르지 않습니다.

  • 앱은 APP_CRYPTO_PROVIDERS_VAULT_TRANSIT_KEY_NAME=deck-app 같은 key name만 지정합니다.
  • 실제 최신 version은 Vault가 관리합니다.
  • ciphertext 안의 key-ref는 결과로 기록되는 값입니다.

SaaS 기준으로는 보통 이 정도만 알아도 충분합니다.

  • 기본 운영은 Vault key-name 중심
  • ENV 다중버전 관리는 실제 회전 요구가 생길 때 도입

key-ref

key-ref는 이 암호문이 어떤 KEK 버전으로 감싸졌는지를 나타냅니다.

다만 App manual에서는 핵심 개념으로 보지 않아도 됩니다.

  • 마이그레이션 핵심은 provider-idwrapped-dek가 현재 provider 체계와 맞는지입니다.
  • key-ref는 복호화와 rewrap() 내부 처리에는 필요하지만, 운영자가 포맷을 읽을 때는 "어느 키 버전으로 포장됐는가" 정도만 이해하면 충분합니다.

wrapped-dek

wrapped-dek실제 DEK를 KEK로 감싼 값입니다.

이 값이 있어야 payload를 복호화할 수 있습니다.
하지만 이 값 자체가 plaintext DEK는 아닙니다.

현재 포맷에서 wrapped-dek를 일반 Base64가 아니라 base64url metadata로 넣는 이유는, provider마다 내부 문자열 형식이 다를 수 있기 때문입니다.

예:

  • ENV: 앱 내부 KEK로 감싼 DEK
  • VAULT_TRANSIT: vault:v7:... 같은 Vault ciphertext 문자열

wrapped-dek는 "실제 DEK 바이트"가 아니라 "provider가 이해하는 wrapped form"을 envelope에 안전하게 싣는 필드입니다.

한 줄로 정리하면:

  • wrapped-dek본문을 풀기 위한 키를 다시 감싼 값
  • ciphertext실제 본문 암호문

iv

ivInitialization Vector입니다.

  • payload를 암호화할 때 같이 쓰는 값입니다.
  • 같은 plaintext라도 iv가 달라지면 다른 ciphertext가 나옵니다.
  • 비밀키는 아니지만 복호화에 꼭 필요합니다.

현재 구현에서는 AES-GCM용 IV가 Base64로 저장됩니다.

ciphertext

ciphertext실제 데이터 본문을 암호화한 결과입니다.

사용자가 저장하려던 진짜 데이터는 여기에 들어 있습니다.
즉 envelope 전체에서 실제 payload 본문은 ciphertext입니다.

ciphertext를 일반 Base64로 저장하는 이유는 이 값이 이미 AES-GCM 결과 바이트이기 때문입니다. 별도 metadata 해석이 필요한 문자열이 아니라, 바이트 배열을 안정적으로 문자열로 바꾸기만 하면 됩니다.

암호화/복호화 흐름

rewrap()은 무엇을 바꾸는가

rewrap()은 payload 전체를 다시 암호화하지 않습니다.

유지되는 값:

  • iv
  • ciphertext

바뀌는 값:

  • provider-id
  • key-ref
  • wrapped-dek

rewrap()본문 재암호화가 아니라 DEK 포장만 교체하는 작업입니다.

언제 rewrap()이 필요한가

아래 중 하나면 rewrap() 대상입니다.

  • provider-id가 현재 provider와 다른 경우
  • key-ref가 현재 active key와 다른 경우

정리하면:

  1. 신규 암호화는 현재 provider와 현재 keyRef를 사용합니다.
  2. 기존 ciphertext는 예전 provider/keyRef를 들고 있을 수 있습니다.
  3. 이런 값은 needsRewrap()로 판정하고, 필요할 때만 rewrap()합니다.

암호화 대상 예시

  • TOTP secret
  • backup codes
  • 외부 연동 credential
  • @Encrypted가 적용된 기타 문자열 필드

로컬 검증 메모

기록할 항목:

  • 어떤 동작으로 암호화 저장을 유도했는지
  • 어떤 컬럼에 어떤 prefix가 저장됐는지
  • 평문이 직접 저장되지 않았는지
  • rotation 이후 needsRewrap()가 어떻게 바뀌는지
  • rewrap() 후 어떤 필드가 유지되고 어떤 필드가 바뀌는지

실제 관찰 결과

Vault only

  • 앱 로그:
    • Configured KEK providers: current=VAULT_TRANSIT, registered=VAULT_TRANSIT
  • user@deck.io에서 TOTP를 활성화하면 아래 두 컬럼이 저장됩니다.
    • users.totp_secret
    • users.totp_backup_codes
  • 둘 다 실제로 v1.VAULT_TRANSIT... prefix로 저장됐습니다.
  • 비밀번호는 같은 방식으로 암호화하지 않고 BCrypt 해시로 저장됩니다.
    • 예: user_identities.password_hash = $2a$10$...

Vault rotate

  • Vault key deck-app를 rotate하면 latest_version1 -> 2 -> 3으로 증가합니다.
  • 기존 ciphertext는 rotate만으로 자동 변경되지 않습니다.
  • currentKeyRef()는 짧은 cache TTL을 사용하므로 rotate 직후 바로 저장하면 이전 version이 잠시 유지될 수 있습니다.
  • 실측에서는 key metadata cache ttl = 30s 때문에:
    • rotate 직후 저장: 아직 v1
    • 30초 후 다시 저장: v2

Vault rewrap()

  • 실제 v2 -> v3 rewrap을 수행했습니다.
  • 바뀐 값:
    • key-ref: v2 -> v3
    • wrapped-dek: vault:v2:... -> vault:v3:...
  • 유지된 값:
    • iv
    • ciphertext
  • v1, v2, v3 ciphertext를 각각 복호화해도 plaintext는 동일했습니다.
  • v3 상태에서 실제 TOTP 로그인도 성공했습니다.

혼용 모드

  • current=ENV, registered=ENV, VAULT_TRANSIT로 앱을 띄우면:
    • 기존 VAULT_TRANSIT v3 ciphertext 복호화 성공
    • 저장 후 ENV v1로 전환
  • 같은 구성을 active-key-ref=v2로 다시 띄우면:
    • 기존 ENV v1 ciphertext 복호화 성공
    • 저장 후 ENV v2로 전환

정리:

  1. 읽기는 ciphertext의 provider-idkey-ref를 따른다.
  2. 쓰기는 current-provider와 현재 active key를 따른다.
  3. 따라서 VAULT_TRANSIT -> ENV v1 -> ENV v2처럼 단계적으로 이동할 수 있다.