Skip to content

캡슐 본문 AES-256-GCM 봉투 암호화 모듈 구현 - #24

Open
cfcromn wants to merge 5 commits into
developfrom
feature/23-capsule-content-encryption
Open

캡슐 본문 AES-256-GCM 봉투 암호화 모듈 구현#24
cfcromn wants to merge 5 commits into
developfrom
feature/23-capsule-content-encryption

Conversation

@cfcromn

@cfcromn cfcromn commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

✨ 작업 내용

캡슐 본문을 AES-256-GCM 봉투 암호화로 저장하는 모듈을 추가했습니다. timecapsule 도메인 API의 선행 작업입니다.

  • global/crypto/ 신규 — CryptoProperties, CryptoConfig, ContentCipher, EncryptedStringConverter
  • TimeCapsule.content@Convert 부착 (기존 파일 변경은 이 지점과 아래 @Lob 수정뿐)
  • mudda.crypto.master-key 설정 추가 + 기동 시 32바이트 검증
  • 단위 15개 + Testcontainers 통합 4개 테스트 추가

저장 포맷

컬럼 추가 없이 기존 content TEXT에 자기서술적 blob 하나로 저장합니다. 기존 데이터가 0건이라 Flyway 마이그레이션이 필요 없습니다.

v1:<b64(dekNonce)>:<b64(wrappedDek)>:<b64(contentNonce)>:<b64(ciphertext‖tag)>

캡슐마다 DEK를 분리한 이유는 두 가지입니다. ① 키 회전 시 본문 전체 재암호화 대신 캡슐당 wrapped DEK만 다시 감싸면 됩니다. ② 단일 키로 암호화하는 데이터 양을 작게 유지해 GCM nonce 충돌을 비이슈로 만듭니다.

v1 태그는 미리 넣되 키는 1개만 지원합니다. 실제 회전이 필요해지면 다중 키 맵으로 확장하며, 포맷이 준비돼 있어 재마이그레이션이 없습니다.


🔍 리뷰 시 참고사항

⚠️ 배포 전 필수

MUDDA_MASTER_KEY배포 시크릿에 먼저 등록해야 합니다. 기본값을 일부러 주지 않았으므로 미설정 시 애플리케이션이 기동에 실패합니다. 그리고 이 키를 분실하면 저장된 모든 캡슐 본문이 영구 복구 불가입니다.

openssl rand -base64 32

작업 중 발견한 버그를 함께 고쳤습니다 (범위 확대)

TimeCapsule.contentGuestbook.content에 붙어 있던 @Lob이 실제로는 PostgreSQL large object 매핑이었습니다. 통합 테스트에서 원시 컬럼을 읽어보니 값이 19921 — 본문이 아니라 pg_largeobject를 가리키는 OID였습니다.

  • 스키마는 TEXT인데 엔티티는 large object를 쓰고 있어 선언과 실제가 어긋난 상태였습니다
  • large object는 행이 삭제돼도 함께 지워지지 않습니다. 캡슐/방명록을 지워도 본문이 DB에 영구히 남습니다
  • TEXT는 길이 제한이 없어 이 우회로 얻는 이득이 없습니다

@Lob을 제거해 두 엔티티 모두 컬럼에 직접 쓰도록 고쳤습니다. 데이터가 아직 0건이라 마이그레이션 없이 정리 가능한 시점이었습니다.

제외 범위

  • Guestbook.content 암호화 — 이번 PR은 @Lob 버그만 같이 고치고 암호화는 적용하지 않았습니다. 방명록은 캡슐을 연 사람들이 공유하는 글이라 "봉인된 본문"과 기밀성 등급이 다르고, 캡슐 본문과 달리 페이지 단위로 N건씩 조회되어 복호화 비용 구조가 다릅니다. feat: 방명록 본문 암호화 적용 검토 및 적용 #25 에서 적용 여부와 projection 전략을 함께 다룹니다.
  • Shamir's Secret Sharing, 키 회전 실행 로직, 타임캡슐 CRUD API, passwordHash/answerHash 해싱

설계 결정 (반박 환영)

  • Shamir's Secret Sharing 제외 — 스키마에 threshold/share 컬럼이 없고 용도가 확정되지 않았습니다. 근거 없는 커스텀 암호 구현이 가장 비싼 부채라 뺐습니다. CLAUDE.md 기술스택 표에는 유지하며, 요구사항이 생기면 별도 이슈로 진행합니다.
  • BouncyCastle 대신 JDK JCE — BC는 통상 Shamir의 GF(256) 연산 때문에 도입하는데 그걸 뺐고, 순수 AES-256-GCM은 SunJCE와 동일합니다. 코드는 provider 무관하게 작성했으므로 BC 전환은 Cipher.getInstance(..., "BC") 1줄입니다. bcprov 의존성은 남겨뒀습니다.
  • 봉투 암호화(서버 KEK)의 한계lockType = NONE이 기본 시나리오라 사용자 제공 비밀이 없고, 좌표는 지오펜싱 때문에 어차피 평문이라 파생 키로 쓸 수 없습니다. 따라서 서버가 탈취되면 복호화가 가능합니다. DB 덤프 유출은 막지만 CLAUDE.md의 "서버는 평문을 볼 수 없다"를 문자 그대로 만족하지는 않습니다. 이걸 완전히 만족하려면 클라이언트 사이드 암호화가 필요하고, 그건 백엔드 범위를 넘습니다.
  • 컨버터 방식 — 서비스 계층이 암호화를 "잊을 수 없게" 구조로 강제합니다. 다만 엔티티 로드마다 복호화하므로, timecapsule PR에서 피드/목록 쿼리는 content를 제외한 projection을 써야 합니다. 컨버터 주석에도 남겨뒀습니다.

테스트

EncryptedContentIntegrationTest가 네이티브 쿼리로 원시 컬럼을 직접 읽어 평문 부재를 단언합니다. JPA로 읽으면 그대로 복호화되기 때문에, 이 방식만이 "평문을 저장하지 않는다"를 검증 가능한 주장으로 만듭니다. Hibernate가 컨버터에 ContentCipher 빈을 주입하는지도 이 테스트가 함께 보장합니다.

./gradlew check  →  34개 클래스 198개 테스트, 실패 0

✅ 체크리스트

  • 문서(README, .env.example 등) 변경이 필요한 경우 작성 또는 수정했나요?
  • 작업한 코드가 정상적으로 동작하는 것을 직접 확인했나요?
  • 필요한 경우 테스트 코드를 작성하거나 수정했나요?
  • Merge 대상 브랜치를 올바르게 설정했나요?
  • PR에 관련 없는 작업이 포함되지 않았나요?
  • 적절한 라벨과 리뷰어를 설정했나요?

📎 관련 이슈(선택)

@lob on a String maps to a PostgreSQL large object, so the TEXT column held an
OID pointing into pg_largeobject rather than the content itself. The referenced
large object is also not removed when the row is deleted, orphaning user content
indefinitely. TEXT is unbounded, so the indirection bought nothing.
Puts the crypto boundary in a converter rather than the service layer, so a
service cannot forget to encrypt. Also drops @lob from the field for the same
reason as the guestbook fix: it would have stored a pg_largeobject OID in the
column instead of the cipher envelope.
@cfcromn cfcromn added 1️⃣ Priority: High 우선순위 - 상 ✨ Feature 신규 기능 labels Aug 13, 2026
@cfcromn
cfcromn requested a review from hej090224 August 17, 2026 14:20
@cfcromn cfcromn self-assigned this Aug 17, 2026
@cfcromn
cfcromn marked this pull request as ready for review August 17, 2026 14:20
// No @Lob -- see the note on TimeCapsule.content: it would store a pg_largeobject OID here instead of
// the text, and orphan the object when the row is deleted.
@Column(nullable = false, columnDefinition = "TEXT")
val content: String,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TimeCapsule.content와 같은 이유(@Lob → large object OID 저장 버그)로 @Lob은 여기서도 제거됐지만, EncryptedStringConverter는 붙이지 않아 방명록 본문은 여전히 평문으로 저장됩니다. 캡슐 본문과 같은 성격의 사용자 작성 텍스트인데 이번 PR에서 의도적으로 범위 밖으로 둔 것인지, 별도 이슈로 추적할 계획인지 확인이 필요해 보입니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의도적으로 범위 밖으로 둔 것이 맞고, 지적해주신 대로 PR 본문에 명시가 없어 확인이 어려웠습니다. 본문에 "제외 범위" 섹션을 추가하고 후속 이슈 #25 를 생성했습니다.

범위를 나눈 이유는 두 가지입니다.

  1. 방명록은 캡슐을 연 사람들에게 공유되는 글이라 "봉인된 본문"과 기밀성 등급이 다릅니다. 암호화 적용 여부 자체가 먼저 결정되어야 할 사안이라고 봤습니다.
  2. 컨버터는 엔티티 로드마다 복호화합니다. 방명록은 캡슐 하나당 여러 건이 페이지 단위로 조회되는 구조(idx_guestbook_time_capsule_deleted_created)라, 단건인 캡슐 본문과 달리 목록 조회에서 N건이 한꺼번에 복호화됩니다. @Convert 한 줄로 끝나지 않고 projection 전략을 함께 정해야 제대로 됩니다.

@Lob 제거만 이번 PR에 포함한 것은 그게 암호화와 무관한 별개의 데이터 손실 버그(행 삭제 시 pg_largeobject 고아 객체)였고, 데이터가 0건인 지금이 마이그레이션 없이 고칠 수 있는 유일한 시점이었기 때문입니다.

private val random = SecureRandom()

fun encrypt(plaintext: String): String {
val dek = randomBytes(KEY_LENGTH_BYTES)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEK 원본 바이트 배열(dek)이 wrappedDek/ciphertext 계산에 쓰인 뒤에도 GC될 때까지 힙에 그대로 남습니다. 마스터 키는 CryptoProperties에서 toString 오버라이드까지 해서 노출을 막았는데, 캡슐마다 새로 만들어지는 DEK는 사용 후 dek.fill(0) 같은 클리어 처리가 없어 같은 원칙이 일관되게 적용되지 않았습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제안하신 dek.fill(0)이 실제로 효과가 있는지 확인해봤는데, 이 코드에서는 목적을 달성하지 못합니다.

byte[] raw = new byte[]{1,2,3,4};
SecretKeySpec k = new SecretKeySpec(raw, "AES");
Arrays.fill(raw, (byte) 0);

caller array after fill : [0, 0, 0, 0]
SecretKeySpec.getEncoded: [1, 2, 3, 4]   // 사본은 그대로
destroy(): DestroyFailedException         // 사본은 지울 수 없음

SecretKeySpec 생성자가 배열을 clone() 하기 때문에, 로컬 dek를 0으로 채워도 키 자체는 SecretKeySpec 내부 사본으로 힙에 남습니다. 그리고 SecretKeySpecDestroyable.destroy()를 구현하지 않아 그 사본을 지울 방법이 없습니다. Cipher도 내부적으로 키를 복사합니다.

더 근본적으로는, 힙을 읽을 수 있는 공격자라면 DEK가 아니라 평문 String을 바로 읽습니다. AttributeConverter<String, String> 시그니처상 평문은 불변 String으로 들어오고 JVM에서 String 내용은 0으로 채울 수 없습니다. 실제 비밀이 평문 그대로 힙에 남아 있는 상태에서 그것을 보호하는 DEK만 지우는 것은 방어 효과가 없습니다.

마스터 키의 toString() 오버라이드와는 위협 모델이 다릅니다. 그쪽은 실수로 빈이 로깅될 때의 유출을 막는 것이고, 지금 말씀하신 건 힙 덤프/메모리 스크래핑 대응입니다. 참고로 마스터 키도 zeroize하지 않으며, 애플리케이션 수명 내내 SecretKeySpec으로 상주하는 것이 설계 의도입니다.

이 방향을 제대로 하려면 평문을 String이 아니라 CharArray/ByteArray로 다루는 경로가 필요한데, 그러면 JPA 컨버터를 못 쓰고 암호화 경계가 서비스 계층으로 흩어집니다. CLAUDE.md가 명시적으로 금지하는 구조라 트레이드오프가 맞지 않는다고 판단해 현재 코드를 유지하겠습니다.

다만 지적의 취지(키 재료 수명 최소화)는 타당하고, 프로젝트 컨벤션에 어긋난 부분은 아닙니다. 더 강한 보증이 필요하다면 KMS 같은 외부 키 관리로 KEK 자체를 프로세스 밖으로 빼는 쪽이 실효성 있는 방향이라 생각합니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1️⃣ Priority: High 우선순위 - 상 ✨ Feature 신규 기능

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants