암호화
App은 민감 정보를 평문으로 저장하지 않고, Envelope Encryption 방식으로 저장합니다.
이 문서는 아래를 빠르게 이해하기 위한 문서입니다.
KEK,DEK가 무엇인지- 저장 포맷이 어떻게 생겼는지
provider-id,wrapped-dek,iv,ciphertext가 각각 무엇인지- rotation 이후
rewrap()이 무엇을 바꾸는지
KEK와 DEK
DEK는 실제 데이터 본문을 암호화하는 키입니다.KEK는 DEK를 감싸는 상위 키입니다.- 저장할 때는 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:ENVkey-ref:a2VrLXYx→kek-v1(key-ref는 운영/디버깅용 참고 정보)wrapped-dek: ENV KEK로 감싼 DEK
VAULT_TRANSIT
현재 구현/테스트 형식 기준 예시:
v1.VAULT_TRANSIT.djc.dmF1bHQ6djc6ZW5jcnlwdGVkLWNpcGhlcnRleHQ.NygkTW7d5YqxOvRQ.0nhY/1VsGdGHQTbjSbOyIkoBu95f0CPYELRO4JBcg6ju956FBrpWx8du+8Stf89F
provider-id:VAULT_TRANSITkey-ref:djc→v7(key-ref는 운영/디버깅용 참고 정보)wrapped-dek:dmF1bHQ6djc6ZW5jcnlwdGVkLWNpcGhlcnRleHQ→vault:v7:encrypted-ciphertext
즉 provider가 바뀌면 envelope 전체 구조가 바뀌는 것이 아니라, 같은 v1 포맷 안에서 provider-id, key-ref, wrapped-dek 해석 방식만 달라집니다.
인코딩 규칙
이 포맷은 .로 이어 붙인 문자열이지만, 각 파트가 모두 같은 인코딩을 쓰지는 않습니다.
| 파트 | 인코딩 |
|---|---|
v1 | 리터럴 문자열 |
provider-id | 리터럴 문자열 |
key-ref | base64url without padding |
wrapped-dek | base64url 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-dek | KEK로 감싼 DEK |
iv | payload 암호화에 사용한 초기화 값 |
ciphertext | 실제 데이터 본문 암호문 |
provider-id
provider-id는 이 암호문을 만든 KEK provider를 나타냅니다.
현재 구현 기준 provider는 아래 두 가지입니다.
ENVVAULT_TRANSIT
이 값이 중요한 이유는, 복호화할 때 현재 provider가 아니라 ciphertext에 기록된 provider를 따라가기 때문입니다.
즉 현재 시스템이 VAULT_TRANSIT 모드로 돌고 있어도, 예전에 ENV로 저장된 ciphertext는 계속 읽을 수 있어야 합니다.
Provider 설정
현재 provider 선택과 provider 등록은 분리되어 있습니다.
app.crypto.current-provider: 신규 encrypt/rewrap에 사용할 providerapp.crypto.providers.env: ENV provider 설정app.crypto.providers.vault-transit: Vault Transit provider 설정
중요한 점:
- 현재 provider는 하나만 선택합니다.
- 실제로 등록되는 provider는
providers.*아래에서 값이 채워진 섹션만 해당합니다. - 따라서
vault-transit만 설정하면 Vault only 모드가 되고, env와vault-transit을 둘 다 설정하면 migration용 dual-provider 모드가 됩니다.
application.yaml 사용법
application.yaml 구조는 그대로 두고, 실제 값은 Spring env binding으로 넣을 수 있습니다.
예:
app.crypto.current-provider→APP_CRYPTO_CURRENT_PROVIDERapp.crypto.providers.env.kek→APP_CRYPTO_PROVIDERS_ENV_KEKapp.crypto.providers.vault-transit.key-name→APP_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하나뿐 - 기존
ENVciphertext를 읽을 필요가 없을 때 사용하는 가장 단순한 구조
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_TRANSITciphertext는 계속 읽음 - 신규 저장은
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-id와wrapped-dek가 현재 provider 체계와 맞는지입니다. key-ref는 복호화와rewrap()내부 처리에는 필요하지만, 운영자가 포맷을 읽을 때는 "어느 키 버전으로 포장됐는가" 정도만 이해하면 충분합니다.
wrapped-dek
wrapped-dek는 실제 DEK를 KEK로 감싼 값입니다.
이 값이 있어야 payload를 복호화할 수 있습니다.
하지만 이 값 자체가 plaintext DEK는 아닙니다.
현재 포맷에서 wrapped-dek를 일반 Base64가 아니라 base64url metadata로 넣는 이유는, provider마다 내부 문자열 형식이 다를 수 있기 때문입니다.
예:
ENV: 앱 내부 KEK로 감싼 DEKVAULT_TRANSIT:vault:v7:...같은 Vault ciphertext 문자열
즉 wrapped-dek는 "실제 DEK 바이트"가 아니라 "provider가 이해하는 wrapped form"을 envelope에 안전하게 싣는 필드입니다.
한 줄로 정리하면:
wrapped-dek는 본문을 풀기 위한 키를 다시 감싼 값ciphertext는 실제 본문 암호문
iv
iv는 Initialization Vector입니다.
- payload를 암호화할 때 같이 쓰는 값입니다.
- 같은 plaintext라도
iv가 달라지면 다른 ciphertext가 나옵니다. - 비밀키는 아니지만 복호화에 꼭 필요합니다.
현재 구현에서는 AES-GCM용 IV가 Base64로 저장됩니다.
ciphertext
ciphertext는 실제 데이터 본문을 암호화한 결과입니다.
사용자가 저장하려던 진짜 데이터는 여기에 들어 있습니다.
즉 envelope 전체에서 실제 payload 본문은 ciphertext입니다.
ciphertext를 일반 Base64로 저장하는 이유는 이 값이 이미 AES-GCM 결과 바이트이기 때문입니다. 별도 metadata 해석이 필요한 문자열이 아니라, 바이트 배열을 안정적으로 문자열로 바꾸기만 하면 됩니다.
암호화/복호화 흐름
rewrap()은 무엇을 바꾸는가
rewrap()은 payload 전체를 다시 암호화하지 않습니다.
유지되는 값:
ivciphertext
바뀌는 값:
provider-idkey-refwrapped-dek
즉 rewrap()은 본문 재암호화가 아니라 DEK 포장만 교체하는 작업입니다.
언제 rewrap()이 필요한가
아래 중 하나면 rewrap() 대상입니다.
provider-id가 현재 provider와 다른 경우key-ref가 현재 active key와 다른 경우
정리하면:
- 신규 암호화는 현재 provider와 현재 keyRef를 사용합니다.
- 기존 ciphertext는 예전 provider/keyRef를 들고 있을 수 있습니다.
- 이런 값은
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_secretusers.totp_backup_codes
- 둘 다 실제로
v1.VAULT_TRANSIT...prefix로 저장됐습니다. - 비밀번호는 같은 방식으로 암호화하지 않고
BCrypt해시로 저장됩니다.- 예:
user_identities.password_hash = $2a$10$...
- 예:
Vault rotate
- Vault key
deck-app를 rotate하면latest_version이1 -> 2 -> 3으로 증가합니다. - 기존 ciphertext는 rotate만으로 자동 변경되지 않습니다.
currentKeyRef()는 짧은 cache TTL을 사용하므로 rotate 직후 바로 저장하면 이전 version이 잠시 유지될 수 있습니다.- 실측에서는
key metadata cache ttl = 30s때문에:- rotate 직후 저장: 아직
v1 - 30초 후 다시 저장:
v2
- rotate 직후 저장: 아직
Vault rewrap()
- 실제
v2 -> v3rewrap을 수행했습니다. - 바뀐 값:
key-ref:v2 -> v3wrapped-dek:vault:v2:... -> vault:v3:...
- 유지된 값:
ivciphertext
v1,v2,v3ciphertext를 각각 복호화해도 plaintext는 동일했습니다.v3상태에서 실제 TOTP 로그인도 성공했습니다.
혼용 모드
current=ENV,registered=ENV, VAULT_TRANSIT로 앱을 띄우면:- 기존
VAULT_TRANSIT v3ciphertext 복호화 성공 - 저장 후
ENV v1로 전환
- 기존
- 같은 구성을
active-key-ref=v2로 다시 띄우면:- 기존
ENV v1ciphertext 복호화 성공 - 저장 후
ENV v2로 전환
- 기존
정리:
- 읽기는 ciphertext의
provider-id와key-ref를 따른다. - 쓰기는
current-provider와 현재 active key를 따른다. - 따라서
VAULT_TRANSIT -> ENV v1 -> ENV v2처럼 단계적으로 이동할 수 있다.