캡슐 본문 AES-256-GCM 봉투 암호화 모듈 구현 - #24
Conversation
@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.
| // 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, |
There was a problem hiding this comment.
TimeCapsule.content와 같은 이유(@Lob → large object OID 저장 버그)로 @Lob은 여기서도 제거됐지만, EncryptedStringConverter는 붙이지 않아 방명록 본문은 여전히 평문으로 저장됩니다. 캡슐 본문과 같은 성격의 사용자 작성 텍스트인데 이번 PR에서 의도적으로 범위 밖으로 둔 것인지, 별도 이슈로 추적할 계획인지 확인이 필요해 보입니다.
There was a problem hiding this comment.
의도적으로 범위 밖으로 둔 것이 맞고, 지적해주신 대로 PR 본문에 명시가 없어 확인이 어려웠습니다. 본문에 "제외 범위" 섹션을 추가하고 후속 이슈 #25 를 생성했습니다.
범위를 나눈 이유는 두 가지입니다.
- 방명록은 캡슐을 연 사람들에게 공유되는 글이라 "봉인된 본문"과 기밀성 등급이 다릅니다. 암호화 적용 여부 자체가 먼저 결정되어야 할 사안이라고 봤습니다.
- 컨버터는 엔티티 로드마다 복호화합니다. 방명록은 캡슐 하나당 여러 건이 페이지 단위로 조회되는 구조(
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) |
There was a problem hiding this comment.
DEK 원본 바이트 배열(dek)이 wrappedDek/ciphertext 계산에 쓰인 뒤에도 GC될 때까지 힙에 그대로 남습니다. 마스터 키는 CryptoProperties에서 toString 오버라이드까지 해서 노출을 막았는데, 캡슐마다 새로 만들어지는 DEK는 사용 후 dek.fill(0) 같은 클리어 처리가 없어 같은 원칙이 일관되게 적용되지 않았습니다.
There was a problem hiding this comment.
제안하신 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 내부 사본으로 힙에 남습니다. 그리고 SecretKeySpec은 Destroyable.destroy()를 구현하지 않아 그 사본을 지울 방법이 없습니다. Cipher도 내부적으로 키를 복사합니다.
더 근본적으로는, 힙을 읽을 수 있는 공격자라면 DEK가 아니라 평문 String을 바로 읽습니다. AttributeConverter<String, String> 시그니처상 평문은 불변 String으로 들어오고 JVM에서 String 내용은 0으로 채울 수 없습니다. 실제 비밀이 평문 그대로 힙에 남아 있는 상태에서 그것을 보호하는 DEK만 지우는 것은 방어 효과가 없습니다.
마스터 키의 toString() 오버라이드와는 위협 모델이 다릅니다. 그쪽은 실수로 빈이 로깅될 때의 유출을 막는 것이고, 지금 말씀하신 건 힙 덤프/메모리 스크래핑 대응입니다. 참고로 마스터 키도 zeroize하지 않으며, 애플리케이션 수명 내내 SecretKeySpec으로 상주하는 것이 설계 의도입니다.
이 방향을 제대로 하려면 평문을 String이 아니라 CharArray/ByteArray로 다루는 경로가 필요한데, 그러면 JPA 컨버터를 못 쓰고 암호화 경계가 서비스 계층으로 흩어집니다. CLAUDE.md가 명시적으로 금지하는 구조라 트레이드오프가 맞지 않는다고 판단해 현재 코드를 유지하겠습니다.
다만 지적의 취지(키 재료 수명 최소화)는 타당하고, 프로젝트 컨벤션에 어긋난 부분은 아닙니다. 더 강한 보증이 필요하다면 KMS 같은 외부 키 관리로 KEK 자체를 프로세스 밖으로 빼는 쪽이 실효성 있는 방향이라 생각합니다.
✨ 작업 내용
캡슐 본문을 AES-256-GCM 봉투 암호화로 저장하는 모듈을 추가했습니다. timecapsule 도메인 API의 선행 작업입니다.
global/crypto/신규 —CryptoProperties,CryptoConfig,ContentCipher,EncryptedStringConverterTimeCapsule.content에@Convert부착 (기존 파일 변경은 이 지점과 아래@Lob수정뿐)mudda.crypto.master-key설정 추가 + 기동 시 32바이트 검증저장 포맷
컬럼 추가 없이 기존
content TEXT에 자기서술적 blob 하나로 저장합니다. 기존 데이터가 0건이라 Flyway 마이그레이션이 필요 없습니다.캡슐마다 DEK를 분리한 이유는 두 가지입니다. ① 키 회전 시 본문 전체 재암호화 대신 캡슐당 wrapped DEK만 다시 감싸면 됩니다. ② 단일 키로 암호화하는 데이터 양을 작게 유지해 GCM nonce 충돌을 비이슈로 만듭니다.
v1태그는 미리 넣되 키는 1개만 지원합니다. 실제 회전이 필요해지면 다중 키 맵으로 확장하며, 포맷이 준비돼 있어 재마이그레이션이 없습니다.🔍 리뷰 시 참고사항
MUDDA_MASTER_KEY를 배포 시크릿에 먼저 등록해야 합니다. 기본값을 일부러 주지 않았으므로 미설정 시 애플리케이션이 기동에 실패합니다. 그리고 이 키를 분실하면 저장된 모든 캡슐 본문이 영구 복구 불가입니다.작업 중 발견한 버그를 함께 고쳤습니다 (범위 확대)
TimeCapsule.content와Guestbook.content에 붙어 있던@Lob이 실제로는 PostgreSQL large object 매핑이었습니다. 통합 테스트에서 원시 컬럼을 읽어보니 값이19921— 본문이 아니라pg_largeobject를 가리키는 OID였습니다.TEXT인데 엔티티는 large object를 쓰고 있어 선언과 실제가 어긋난 상태였습니다TEXT는 길이 제한이 없어 이 우회로 얻는 이득이 없습니다@Lob을 제거해 두 엔티티 모두 컬럼에 직접 쓰도록 고쳤습니다. 데이터가 아직 0건이라 마이그레이션 없이 정리 가능한 시점이었습니다.제외 범위
Guestbook.content암호화 — 이번 PR은@Lob버그만 같이 고치고 암호화는 적용하지 않았습니다. 방명록은 캡슐을 연 사람들이 공유하는 글이라 "봉인된 본문"과 기밀성 등급이 다르고, 캡슐 본문과 달리 페이지 단위로 N건씩 조회되어 복호화 비용 구조가 다릅니다. feat: 방명록 본문 암호화 적용 검토 및 적용 #25 에서 적용 여부와 projection 전략을 함께 다룹니다.passwordHash/answerHash해싱설계 결정 (반박 환영)
Cipher.getInstance(..., "BC")1줄입니다.bcprov의존성은 남겨뒀습니다.lockType = NONE이 기본 시나리오라 사용자 제공 비밀이 없고, 좌표는 지오펜싱 때문에 어차피 평문이라 파생 키로 쓸 수 없습니다. 따라서 서버가 탈취되면 복호화가 가능합니다. DB 덤프 유출은 막지만 CLAUDE.md의 "서버는 평문을 볼 수 없다"를 문자 그대로 만족하지는 않습니다. 이걸 완전히 만족하려면 클라이언트 사이드 암호화가 필요하고, 그건 백엔드 범위를 넘습니다.content를 제외한 projection을 써야 합니다. 컨버터 주석에도 남겨뒀습니다.테스트
EncryptedContentIntegrationTest가 네이티브 쿼리로 원시 컬럼을 직접 읽어 평문 부재를 단언합니다. JPA로 읽으면 그대로 복호화되기 때문에, 이 방식만이 "평문을 저장하지 않는다"를 검증 가능한 주장으로 만듭니다. Hibernate가 컨버터에ContentCipher빈을 주입하는지도 이 테스트가 함께 보장합니다.✅ 체크리스트
.env.example등) 변경이 필요한 경우 작성 또는 수정했나요?📎 관련 이슈(선택)