Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package team.cklob.mudda.domain.timecapsule.application

import org.springframework.stereotype.Component
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleEncryptionMode
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleLockType
import team.cklob.mudda.domain.timecapsule.presentation.request.CreateCapsuleRequest
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode

// Decides which encryption mode a capsule gets and refuses payloads that would quietly break the
// guarantee the mode advertises. Kept out of CreateCapsuleService so the whole rule set reads in one
// place -- this is the boundary CLAUDE.md asks to keep explicit rather than scattered.
@Component
class CapsuleEncryptionPolicy {
fun resolveMode(lockType: CapsuleLockType): CapsuleEncryptionMode = when (lockType) {
// An unlocked capsule opens on location alone, and the server stores the location. Any secret it
// withheld from itself it could re-derive, so end-to-end encryption is not achievable here and
// claiming it would be worse than not offering it.
CapsuleLockType.NONE -> CapsuleEncryptionMode.SERVER_ENVELOPE
CapsuleLockType.PASSWORD, CapsuleLockType.QUESTION -> CapsuleEncryptionMode.CLIENT_E2E
}

fun validate(request: CreateCapsuleRequest, mode: CapsuleEncryptionMode) {
when (mode) {
CapsuleEncryptionMode.SERVER_ENVELOPE -> validateServerEnvelope(request)
CapsuleEncryptionMode.CLIENT_E2E -> validateClientE2e(request)
}
}

private fun validateServerEnvelope(request: CreateCapsuleRequest) {
if (request.content.isNullOrBlank()) throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION)
// Rejected rather than ignored: silently dropping key material would leave the client believing it
// created an end-to-end capsule.
if (request.contentCipher != null || !request.keyShares.isNullOrEmpty() || request.keyThreshold != null) {
throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION)
}
}

private fun validateClientE2e(request: CreateCapsuleRequest) {
// The server must never receive a plaintext body for a capsule it is not supposed to be able to read.
if (request.content != null) throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION)
if (request.contentCipher.isNullOrBlank()) throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION)

val shares = request.keyShares
val threshold = request.keyThreshold
if (shares.isNullOrEmpty() || threshold == null) throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION)
if (shares.map { it.index }.toSet().size != shares.size) throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION)

// The guarantee this mode advertises is that the server cannot reconstruct the key on its own. A
// wrapped share does not count: it is ciphertext under a key derived from the lock secret, which the
// server only ever sees as a bcrypt hash. Enforced here rather than trusted to the client, because a
// client bug that sent every share in the clear would silently downgrade the capsule to plaintext
// while still reporting it as end-to-end.
val usableByServer = shares.count { it.isWrapped == false }
Comment thread
exijn marked this conversation as resolved.
if (usableByServer >= threshold) throw BusinessException(ErrorCode.SERVER_HOLDS_KEY_QUORUM)

// Symmetrically, at least one wrapped share has to be present: otherwise the client has no way to
// reach the threshold either and the capsule would be unopenable by anyone.
if (shares.none { it.isWrapped == true }) throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package team.cklob.mudda.domain.timecapsule.application.impl

import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.timecapsule.domain.repository.KeyShareRepository
import java.time.LocalDateTime

// An expired capsule can never be opened again, so its key shares serve no purpose. Keeping them only
// leaves a wrapped share sitting in the database indefinitely, where an attacker who reaches the data can
// grind the lock secret offline for as long as they like. The encryption design calls for removing them
// on a schedule, and this is that job.
//
// Note this deletes only the shares, not the capsule row or its ciphertext: the capsule stays visible as
// an expired capsule, it simply becomes permanently unopenable, which is the intended end state.
@Service
class CleanUpKeyShareService(
private val keyShareRepository: KeyShareRepository,
) {
private val logger = LoggerFactory.getLogger(javaClass)

@Scheduled(cron = "\${capsule.key-share-cleanup.cron:0 30 4 * * *}")
@Transactional
fun execute(): Int {
val deleted = keyShareRepository.deleteSharesOfCapsulesExpiredBefore(LocalDateTime.now())
if (deleted > 0) logger.info("deleted {} key shares belonging to expired capsules", deleted)
return deleted
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.domain.notification.application.impl.NotificationPublisher
import team.cklob.mudda.domain.notification.domain.type.NotificationTargetType
import team.cklob.mudda.domain.notification.domain.type.NotificationType
import team.cklob.mudda.domain.timecapsule.application.CapsuleEncryptionPolicy
import team.cklob.mudda.domain.timecapsule.application.CapsuleProperties
import team.cklob.mudda.domain.timecapsule.domain.entity.CapsuleRecipient
import team.cklob.mudda.domain.timecapsule.domain.entity.KeyShare
import team.cklob.mudda.domain.timecapsule.domain.entity.TimeCapsule
import team.cklob.mudda.domain.timecapsule.domain.repository.CapsuleRecipientRepository
import team.cklob.mudda.domain.timecapsule.domain.repository.KeyShareRepository
import team.cklob.mudda.domain.timecapsule.domain.repository.TimeCapsuleRepository
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleEncryptionMode
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleLockType
import team.cklob.mudda.domain.timecapsule.presentation.request.CreateCapsuleRequest
import team.cklob.mudda.domain.timecapsule.presentation.response.CreateCapsuleResponse
Expand All @@ -36,11 +40,15 @@ class CreateCapsuleService(
private val passwordEncoder: PasswordEncoder,
private val properties: CapsuleProperties,
private val notificationPublisher: NotificationPublisher,
private val keyShareRepository: KeyShareRepository,
private val encryptionPolicy: CapsuleEncryptionPolicy,
) {
@Transactional
fun execute(memberId: Long, request: CreateCapsuleRequest): CreateCapsuleResponse {
val now = LocalDateTime.now()
validate(request, now)
val encryptionMode = encryptionPolicy.resolveMode(request.lockType)
encryptionPolicy.validate(request, encryptionMode)
if (capsuleRepository.countActiveByMemberId(memberId, now) >= properties.maxActivePerMember) {
throw BusinessException(ErrorCode.CAPSULE_LIMIT_EXCEEDED)
}
Expand All @@ -58,7 +66,11 @@ class CreateCapsuleService(
TimeCapsule(
member = member,
name = request.name.trim(),
content = request.content,
// Exactly one of the two is set, enforced by CapsuleEncryptionPolicy. For CLIENT_E2E the
// stored value is the client's ciphertext, which the server cannot open.
content = request.content ?: request.contentCipher,
encryptionMode = encryptionMode,
keyThreshold = request.keyThreshold,
visibility = request.visibility,
lockType = request.lockType,
passwordHash = request.password?.let(passwordEncoder::encode),
Expand All @@ -70,6 +82,13 @@ class CreateCapsuleService(
expiredAt = request.expiredAt,
),
)
if (encryptionMode == CapsuleEncryptionMode.CLIENT_E2E) {
keyShareRepository.saveAll(
request.keyShares.orEmpty().map {
KeyShare(capsule, requireNotNull(it.index), requireNotNull(it.data), requireNotNull(it.isWrapped))
},
)
}
recipientRepository.saveAll(recipients.values.map { CapsuleRecipient(it, capsule) })
media.forEach { it.timeCapsule = capsule }
// Recipients are told a capsule is waiting for them, but not where or what is in it -- the whole
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ import team.cklob.mudda.domain.timecapsule.application.CapsuleAccessPolicy
import team.cklob.mudda.domain.timecapsule.domain.entity.CapsuleOpen
import team.cklob.mudda.domain.timecapsule.domain.entity.TimeCapsule
import team.cklob.mudda.domain.timecapsule.domain.repository.CapsuleOpenRepository
import team.cklob.mudda.domain.timecapsule.domain.repository.KeyShareRepository
import team.cklob.mudda.domain.timecapsule.domain.repository.CapsuleRecipientRepository
import team.cklob.mudda.domain.timecapsule.domain.repository.TimeCapsuleRepository
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleEncryptionMode
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleLockType
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleVisibility
import team.cklob.mudda.domain.timecapsule.presentation.request.OpenCapsuleRequest
import team.cklob.mudda.domain.timecapsule.presentation.response.KeyShareResponse
import team.cklob.mudda.domain.timecapsule.presentation.response.MediaResponse
import team.cklob.mudda.domain.timecapsule.presentation.response.OpenCapsuleResponse
import team.cklob.mudda.global.exception.BusinessException
Expand All @@ -42,6 +45,7 @@ class OpenCapsuleService(
private val accessPolicy: CapsuleAccessPolicy,
private val notificationPublisher: NotificationPublisher,
private val feedBroadcaster: FeedBroadcaster,
private val keyShareRepository: KeyShareRepository,
) {
@Transactional
fun execute(memberId: Long, capsuleId: Long, request: OpenCapsuleRequest): OpenCapsuleResponse {
Expand Down Expand Up @@ -69,7 +73,28 @@ class OpenCapsuleService(
val media = mediaRepository.findAllByTimeCapsuleId(capsuleId).map {
MediaResponse(requireNotNull(it.id), mediaStorage.createAccessUrl(it.s3Key).url, it.mediaType)
}
return OpenCapsuleResponse(capsuleId, capsule.name, capsule.content.orEmpty(), writer(capsule), media, opened.openedAt)
// The lock has been verified by this point, which is what gates release of the server's shares. For a
// CLIENT_E2E capsule the server hands back its sub-threshold shares and the blob and stops there --
// it has no key to decrypt with, and returning `content` would be a lie about what it holds.
val e2e = capsule.encryptionMode == CapsuleEncryptionMode.CLIENT_E2E
Comment thread
exijn marked this conversation as resolved.
val shares = if (e2e) {
keyShareRepository.findAllByTimeCapsuleIdOrderByShareIndex(capsuleId)
.map { KeyShareResponse(it.shareIndex, it.shareData, it.isWrapped) }
} else {
emptyList()
}
return OpenCapsuleResponse(
capsuleId = capsuleId,
title = capsule.name,
encryptionMode = capsule.encryptionMode,
content = capsule.content.takeUnless { e2e },
contentCipher = capsule.content.takeIf { e2e },
keyShares = shares,
keyThreshold = capsule.keyThreshold,
writer = writer(capsule),
media = media,
openedAt = opened.openedAt,
)
}

// Only the first open is newsworthy: re-opening a capsule you already unlocked must not notify the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package team.cklob.mudda.domain.timecapsule.domain.entity

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import team.cklob.mudda.global.common.entity.BaseCreatedAtEntity

// One Shamir share of a CLIENT_E2E capsule's content key.
//
// The server deliberately holds fewer usable shares than the threshold. A wrapped share is stored as
// ciphertext under a key derived from the capsule's password or answer, which the server only knows as a
// bcrypt hash -- so it counts toward the client's quorum but never toward the server's.
@Entity
@Table(
name = "tbl_key_share",
uniqueConstraints = [
UniqueConstraint(name = "uq_key_share_capsule_index", columnNames = ["time_capsule_id", "share_index"]),
],
)
class KeyShare(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "time_capsule_id", nullable = false)
val timeCapsule: TimeCapsule,

// The Shamir x-coordinate. Interpolation cannot recover the key without it, so it travels with the
// share rather than being implied by row order.
@Column(name = "share_index", nullable = false)
val shareIndex: Int,

@Column(name = "share_data", nullable = false, columnDefinition = "TEXT")
val shareData: String,

@Column(name = "is_wrapped", nullable = false)
val isWrapped: Boolean,

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
) : BaseCreatedAtEntity()
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import org.locationtech.jts.geom.Point
import team.cklob.mudda.domain.member.domain.entity.Member
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleEncryptionMode
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleLockType
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleVisibility
import team.cklob.mudda.global.common.entity.BaseTimeEntity
Expand All @@ -30,14 +31,27 @@ class TimeCapsule(
@Column(nullable = false, length = 255)
val name: String,

// Encrypted at rest -- the column only ever holds a ContentCipher envelope blob, never the body itself.
// Never holds the body in plaintext. For SERVER_ENVELOPE capsules it is a ContentCipher envelope the
// server can open; for CLIENT_E2E capsules it is a blob the client encrypted under a key the server
// never received, and the converter simply adds a second at-rest layer over ciphertext.
//
// No @Lob: on PostgreSQL that maps a String to a large object, so the column would hold an OID pointing
// into pg_largeobject rather than the value itself, and the referenced object is not removed when the
// row is deleted. `TEXT` is unbounded, so nothing is gained by the large-object indirection anyway.
@Convert(converter = EncryptedStringConverter::class)
@Column(columnDefinition = "TEXT")
val content: String? = null,

// Which side holds the key. Read the mode rather than inferring it from lockType: the open path must
// never hand back a body for a capsule the server is not supposed to be able to read.
@Enumerated(EnumType.STRING)
@Column(name = "encryption_mode", nullable = false, length = 20)
val encryptionMode: CapsuleEncryptionMode = CapsuleEncryptionMode.SERVER_ENVELOPE,

// Number of shares needed to rebuild the content key. Null for SERVER_ENVELOPE capsules.
@Column(name = "key_threshold")
val keyThreshold: Int? = null,

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
val visibility: CapsuleVisibility,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package team.cklob.mudda.domain.timecapsule.domain.repository

import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import team.cklob.mudda.domain.timecapsule.domain.entity.KeyShare
import java.time.LocalDateTime

interface KeyShareRepository : JpaRepository<KeyShare, Long> {
fun findAllByTimeCapsuleIdOrderByShareIndex(timeCapsuleId: Long): List<KeyShare>

// An expired capsule can never be opened again, so its shares are dead weight that would only widen
// the window for an offline attack on a wrapped share. The encryption design calls for removing them
// on a schedule.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
DELETE FROM KeyShare k WHERE k.timeCapsule.id IN (
SELECT c.id FROM TimeCapsule c WHERE c.expiredAt IS NOT NULL AND c.expiredAt <= :now
)
""",
)
fun deleteSharesOfCapsulesExpiredBefore(@Param("now") now: LocalDateTime): Int
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package team.cklob.mudda.domain.timecapsule.domain.type

// Which side holds the key to a capsule's body.
enum class CapsuleEncryptionMode {
// The server encrypts and decrypts the body with its own master key, so it can read the plaintext.
// Used for lockType = NONE, where end-to-end encryption is impossible by construction: the only
// unlock condition is being at the capsule's coordinates, and the server stores those coordinates,
// so any secret it could withhold from itself it could also re-derive.
SERVER_ENVELOPE,

// The client encrypts the body under a key the server never sees. The server keeps an opaque blob plus
// fewer key shares than the reconstruction threshold, so it cannot recover the body on its own.
// Requires a lock (PASSWORD or QUESTION): the lock secret is the one input the server does not hold.
CLIENT_E2E,
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,14 @@ class CapsuleController(
) {
@Operation(
summary = "타임캡슐 묻기",
description = "지정한 좌표에 캡슐을 묻습니다. 수신자는 친구여야 하며, 첨부 미디어는 본인이 업로드했고 아직 다른 캡슐에 붙지 않은 것이어야 합니다. " +
"잠금 유형에 따라 password 또는 question/answer 조합이 필요합니다.",
description = "지정한 좌표에 캡슐을 묻습니다. 수신자는 친구여야 하며, 첨부 미디어는 본인이 업로드했고 아직 다른 캡슐에 붙지 않은 것이어야 합니다.\n\n" +
"**암호화 모드는 lockType에서 결정됩니다.**\n" +
"- `NONE` → SERVER_ENVELOPE. 평문 `content`를 보내면 서버가 암호화해 보관합니다. " +
"이 경우 서버는 내용을 읽을 수 있습니다. 잠금이 없는 캡슐의 열람 조건은 좌표뿐이고 서버가 좌표를 알고 있어 종단간 암호화가 원리적으로 불가능합니다.\n" +
"- `PASSWORD`/`QUESTION` → CLIENT_E2E. 클라이언트가 CEK로 암호화한 `contentCipher`, Shamir 조각 `keyShares`, " +
"임계값 `keyThreshold`를 보냅니다. 평문 `content`는 보낼 수 없습니다. " +
"서버가 보관하는 평문 조각 수는 임계값보다 적어야 하며(그렇지 않으면 SERVER_HOLDS_KEY_QUORUM), " +
"잠금 비밀로 감싼 조각(`isWrapped=true`)이 최소 하나 있어야 합니다.",
)
@SwaggerApiResponses(
SwaggerApiResponse(responseCode = "201", description = "생성 성공"),
Expand Down Expand Up @@ -133,8 +139,13 @@ class CapsuleController(

@Operation(
summary = "캡슐 열람",
description = "현재 위치를 서버에서 PostGIS로 재검증한 뒤 캡슐 내용을 반환합니다. 최초 열람 시에만 잠금(비밀번호·질문)을 검증하며, " +
"재열람은 위치만 다시 검증합니다. 최초 열람은 작성자에게 알림을 보내고, 공개 캡슐이면 발견 피드에 실립니다.",
description = "현재 위치를 서버에서 PostGIS로 재검증한 뒤 캡슐을 엽니다. 최초 열람 시에만 잠금(비밀번호·질문)을 검증하며, " +
"재열람은 위치만 다시 검증합니다. 최초 열람은 작성자에게 알림을 보내고, 공개 캡슐이면 발견 피드에 실립니다.\n\n" +
"**응답은 encryptionMode에 따라 달라집니다.**\n" +
"- `SERVER_ENVELOPE` → `content`에 평문이 담깁니다.\n" +
"- `CLIENT_E2E` → `content`는 null이고 `contentCipher`와 `keyShares`가 반환됩니다. " +
"클라이언트가 비밀번호·정답으로 `isWrapped=true` 조각을 풀고, `keyThreshold`개를 모아 CEK를 복원해 직접 복호화해야 합니다. " +
"서버는 이 캡슐의 평문을 가지고 있지 않습니다.",
)
@SwaggerApiResponses(
SwaggerApiResponse(responseCode = "200", description = "열람 성공"),
Expand Down
Loading