Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,107 @@
package team.cklob.mudda.domain.timecapsule.application

import org.springframework.stereotype.Component
import java.util.Base64
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.request.KeyShareRequest
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) {
validateQuestionText(request)
when (mode) {
CapsuleEncryptionMode.SERVER_ENVELOPE -> validateServerEnvelope(request)
CapsuleEncryptionMode.CLIENT_E2E -> validateClientE2e(request)
}
}

// The question is the only lock field the server still receives -- it is prompt text shown to the
// opener, never the answer.
private fun validateQuestionText(request: CreateCapsuleRequest) {
val valid = when (request.lockType) {
CapsuleLockType.QUESTION -> !request.question.isNullOrBlank()
CapsuleLockType.NONE, CapsuleLockType.PASSWORD -> request.question == null
}
if (!valid) throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION)
}

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)

validateWrappedShapes(shares)
}

// `isWrapped` is a client assertion, and the server fundamentally cannot verify it: well-encrypted
// bytes are indistinguishable from random ones, so no check can prove a share is really wrapped. What
// is checkable is its shape. A share wrapped with AES-256-GCM as the protocol specifies carries a
// 12-byte nonce and a 16-byte tag on top of the plaintext share, so a wrapped share must be exactly
// AEAD_OVERHEAD_BYTES longer than a plaintext one.
//
// This catches the realistic failure -- a client bug that labels raw shares as wrapped and silently
// downgrades the capsule to server-readable. It does not stop a client that deliberately pads raw
// bytes to the right length. That residual case is accepted: a client lying here only exposes its own
// capsule, whose secret it already holds, so the guarantee is precisely "the server cannot read a
// capsule whose owner followed the protocol".
private fun validateWrappedShapes(shares: List<KeyShareRequest>) {
val decoded = shares.map {
it to runCatching { Base64.getDecoder().decode(it.data) }
.getOrElse { throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION) }
}
// With no plaintext share there is no baseline length to compare against, and the server holding
// zero usable shares is already the strongest case, so there is nothing left to check.
val plainLength = decoded.firstOrNull { (share, _) -> share.isWrapped == false }?.second?.size ?: return

decoded.forEach { (share, bytes) ->
val expected = if (share.isWrapped == true) plainLength + AEAD_OVERHEAD_BYTES else plainLength
if (bytes.size != expected) throw BusinessException(ErrorCode.INVALID_CAPSULE_ENCRYPTION)
}
}

private companion object {
// AES-GCM with a 96-bit nonce and a 128-bit tag, as the encryption design specifies.
const val AEAD_OVERHEAD_BYTES = 12 + 16
}
}
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 @@ -3,7 +3,6 @@ package team.cklob.mudda.domain.timecapsule.application.impl
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.PrecisionModel
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.block.domain.repository.BlockRepository
Expand All @@ -13,12 +12,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.CapsuleLockType
import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleEncryptionMode
import team.cklob.mudda.domain.timecapsule.presentation.request.CreateCapsuleRequest
import team.cklob.mudda.domain.timecapsule.presentation.response.CreateCapsuleResponse
import team.cklob.mudda.global.exception.BusinessException
Expand All @@ -33,14 +35,17 @@ class CreateCapsuleService(
private val friendRepository: FriendRepository,
private val blockRepository: BlockRepository,
private val mediaRepository: MediaRepository,
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,18 +63,29 @@ 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),
// question is display text only; the answer never reaches the server, so there is nothing to
// hash and no password_hash/answer_hash to write.
question = request.question?.trim(),
answerHash = request.answer?.trim()?.lowercase()?.let(passwordEncoder::encode),
location = location,
openRadiusMeter = properties.openRadiusMeter,
openAt = request.openAt,
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 All @@ -95,11 +111,8 @@ class CreateCapsuleService(
if (request.openAt.isBefore(now) || request.expiredAt?.let { !it.isAfter(request.openAt) || it.isAfter(request.openAt.plusYears(properties.maxExpirationYears)) } == true) {
throw BusinessException(ErrorCode.INVALID_INPUT)
}
val validLock = when (request.lockType) {
CapsuleLockType.NONE -> request.password == null && request.question == null && request.answer == null
CapsuleLockType.PASSWORD -> !request.password.isNullOrBlank() && request.question == null && request.answer == null
CapsuleLockType.QUESTION -> request.password == null && !request.question.isNullOrBlank() && !request.answer.isNullOrBlank()
}
if (!validLock) throw BusinessException(ErrorCode.INVALID_INPUT)
// Lock field consistency moved to CapsuleEncryptionPolicy: with the secret no longer sent to the
// server, the only lock field left to check is the question text, and that check belongs next to the
// rest of the encryption contract.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package team.cklob.mudda.domain.timecapsule.application.impl
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.PrecisionModel
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.media.application.MediaStorage
Expand All @@ -18,11 +17,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 @@ -38,10 +40,10 @@ class OpenCapsuleService(
private val memberRepository: MemberRepository,
private val mediaRepository: MediaRepository,
private val mediaStorage: MediaStorage,
private val passwordEncoder: PasswordEncoder,
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 All @@ -56,7 +58,12 @@ class OpenCapsuleService(
}
var opened = openRepository.findByTimeCapsuleIdAndMemberId(capsuleId, memberId).orElse(null)
if (opened == null) {
verifyLock(capsule.lockType, capsule.passwordHash, capsule.answerHash, request)
// No lock check here on purpose. The server verifies location and access only; proving knowledge
// of the lock secret happens on the client, when it unwraps its key share. Verifying server-side
// would mean receiving the secret in plaintext, and a server that has seen it can derive the same
// wrapping key and open every capsule it stores -- which is exactly the guarantee CLIENT_E2E is
// supposed to provide. A wrong secret fails the share's GCM tag check instead, which is strictly
// stronger than a bcrypt comparison: it cannot be bypassed by anything the server does.
val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) }
val openLocation = GeometryFactory(PrecisionModel(), 4326).createPoint(Coordinate(request.longitude, request.latitude))
opened = openRepository.save(CapsuleOpen(capsule, member, now, openLocation))
Expand All @@ -69,7 +76,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 All @@ -91,13 +119,4 @@ class OpenCapsuleService(
feedBroadcaster.broadcast(FeedResponse.from(opened))
}
}

private fun verifyLock(lockType: CapsuleLockType, passwordHash: String?, answerHash: String?, request: OpenCapsuleRequest) {
val matches = when (lockType) {
CapsuleLockType.NONE -> true
CapsuleLockType.PASSWORD -> request.password?.let { passwordEncoder.matches(it, passwordHash) } == true
CapsuleLockType.QUESTION -> request.answer?.trim()?.lowercase()?.let { passwordEncoder.matches(it, answerHash) } == true
}
if (!matches) throw CapsuleException(ErrorCode.CAPSULE_LOCK_FAILED)
}
}
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()
Loading