Skip to content

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

Merged
cfcromn merged 6 commits into
developfrom
feature/23-capsule-content-encryption
Aug 20, 2026
Merged

캡슐 본문 AES-256-GCM 봉투 암호화 모듈 구현#24
cfcromn merged 6 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
Comment thread src/main/kotlin/team/cklob/mudda/global/crypto/ContentCipher.kt
@hej090224

Copy link
Copy Markdown
Member

ContentCipherTest.kt 90번째 줄 (round-trips multibyte text and emoji)의 문자열 리터럴 안에 이스케이프되지 않은 원시 NUL 바이트(0x00)가 그대로 들어가 있습니다.

val plaintext = "한글 · 日本語 · emoji [emoji] ·  [NUL byte]  control"

이 바이트 때문에 git/GitHub이 파일 전체를 바이너리로 인식해서 이 PR diff에서 "Binary files differ"로만 표시되고, 인라인 코멘트도 걸 수 없는 상태였습니다(라인 diff/blame도 이후 계속 깨질 것으로 보입니다). 유니코드 이스케이프(U+0000)로 바꾸면 의도한 컨트롤 문자 테스트는 그대로 유지하면서 파일이 다시 텍스트로 인식됩니다.

…ng it raw

A raw 0x00 in the source made git classify the whole file as binary, so this PR
showed it as "Binary files differ" and line diffs, blame and inline review
comments were all unavailable on it. The \u0000 escape compiles to the same
string, so the control-character round-trip is still covered.
@cfcromn

cfcromn commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

4766cb2 에서 반영했습니다. 정확한 지적이었고, 재현까지 확인했습니다.

NUL byte count: 1
  offset=3349 line=90 col=67

git diff --numstat develop...HEAD -- ContentCipherTest.kt
-	-	src/.../ContentCipherTest.kt     # git이 바이너리로 판정

file ContentCipherTest.kt
... : data                              # 텍스트로 인식조차 안 됨

수정 후:

118	0	src/.../ContentCipherTest.kt
... : C++ source text, Unicode text, UTF-8 text

원시 0x00을 \u0000 이스케이프로 바꿨습니다. Kotlin 컴파일 결과 문자열은 동일해서 컨트롤 문자 라운드트립 검증은 그대로 유지됩니다. 같은 실수가 반복되지 않도록 테스트 위에 이유를 주석으로 남겼고, 실제로 컨트롤 문자도 검증한다는 걸 드러내려고 테스트 이름을 round-trips multibyte text, emoji and control characters 로 바꿨습니다.

PR의 나머지 변경 파일에도 NUL 바이트가 있는지 전수 확인했고 이 파일 하나뿐이었습니다. ./gradlew check 재실행 결과 34개 클래스 198개 테스트 전부 통과합니다.

이 코멘트 덕분에 diff/blame이 살아났습니다. 감사합니다.

@cfcromn
cfcromn merged commit 2535ee6 into develop Aug 20, 2026
2 checks passed
@cfcromn
cfcromn deleted the feature/23-capsule-content-encryption branch August 20, 2026 00:01
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