diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/CapsuleEncryptionPolicy.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/CapsuleEncryptionPolicy.kt new file mode 100644 index 0000000..a378950 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/CapsuleEncryptionPolicy.kt @@ -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 } + 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) { + 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 + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CleanUpKeyShareService.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CleanUpKeyShareService.kt new file mode 100644 index 0000000..25b50a9 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CleanUpKeyShareService.kt @@ -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 + } +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CreateCapsuleService.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CreateCapsuleService.kt index d1aeeb8..66b2e3a 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CreateCapsuleService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/CreateCapsuleService.kt @@ -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 @@ -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 @@ -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) } @@ -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 @@ -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. } } diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/OpenCapsuleService.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/OpenCapsuleService.kt index 6cde83f..8aa27ff 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/OpenCapsuleService.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/OpenCapsuleService.kt @@ -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 @@ -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 @@ -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 { @@ -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)) @@ -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 + 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 @@ -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) - } } diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/entity/KeyShare.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/entity/KeyShare.kt new file mode 100644 index 0000000..60db9e9 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/entity/KeyShare.kt @@ -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() diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/entity/TimeCapsule.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/entity/TimeCapsule.kt index 0f1f8eb..45917dc 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/entity/TimeCapsule.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/entity/TimeCapsule.kt @@ -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 @@ -30,7 +31,10 @@ 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. @@ -38,23 +42,34 @@ class TimeCapsule( @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, + // The lock is enforced by the client's ability to unwrap a key share, not by the server. lockType and + // question are kept only so the client knows what to prompt for. + // + // password_hash and answer_hash are intentionally no longer mapped: storing a hash of the lock secret + // bought nothing once verification moved to the client, and it left an offline-guessable artefact next + // to the wrapped share it protects. The columns stay in place (nullable, unwritten) so a previous blue + // container keeps working during a deployment; drop them in a later migration. @Enumerated(EnumType.STRING) @Column(name = "lock_type", nullable = false, length = 20) val lockType: CapsuleLockType, - @Column(name = "password_hash", length = 255) - val passwordHash: String? = null, - @Column(length = 255) val question: String? = null, - @Column(name = "answer_hash", length = 255) - val answerHash: String? = null, - @Column(nullable = false, columnDefinition = "geometry(Point,4326)") val location: Point, diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/repository/KeyShareRepository.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/repository/KeyShareRepository.kt new file mode 100644 index 0000000..8208346 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/repository/KeyShareRepository.kt @@ -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 { + fun findAllByTimeCapsuleIdOrderByShareIndex(timeCapsuleId: Long): List + + // 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 +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/type/CapsuleEncryptionMode.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/type/CapsuleEncryptionMode.kt new file mode 100644 index 0000000..e888e67 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/domain/type/CapsuleEncryptionMode.kt @@ -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, +} diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/controller/CapsuleController.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/controller/CapsuleController.kt index a27f30f..5d99f98 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/controller/CapsuleController.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/controller/CapsuleController.kt @@ -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 = "생성 성공"), @@ -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 = "열람 성공"), diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/request/CapsuleRequests.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/request/CapsuleRequests.kt index 17a02af..669b00b 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/request/CapsuleRequests.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/request/CapsuleRequests.kt @@ -6,6 +6,8 @@ import jakarta.validation.constraints.DecimalMin import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Max import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleLockType import team.cklob.mudda.domain.timecapsule.domain.type.CapsuleVisibility import java.time.LocalDateTime @@ -19,9 +21,36 @@ data class CreateCapsuleRequest( @Schema(description = "캡슐 제목", example = "첫 캡슐") val name: String, - @field:NotBlank - @Schema(description = "캡슐 내용. 저장 시 AES-256-GCM으로 암호화됩니다.", example = "10년 뒤의 나에게") - val content: String, + @Schema( + description = "평문 캡슐 내용. lockType이 NONE일 때만 사용하며, 서버가 AES-256-GCM으로 암호화해 보관합니다. " + + "잠금이 있는 캡슐에서는 서버가 평문을 받아서는 안 되므로 생략하고 contentCipher를 보내야 합니다.", + example = "10년 뒤의 나에게", + nullable = true, + ) + val content: String? = null, + + @Schema( + description = "클라이언트가 CEK로 암호화한 본문 blob(Base64). lockType이 PASSWORD 또는 QUESTION일 때 필수입니다. " + + "서버는 이 값을 해독할 수 없습니다.", + nullable = true, + ) + val contentCipher: String? = null, + + @Schema( + description = "CEK를 Shamir로 분할한 조각 중 서버에 맡길 것들. lockType이 PASSWORD 또는 QUESTION일 때 필수이며, " + + "복원 임계값보다 적은 수의 평문 조각만 포함해야 합니다. 잠금 비밀에서 유도한 키로 감싼 조각은 isWrapped=true로 표시합니다.", + nullable = true, + ) + val keyShares: List? = null, + + @Schema( + description = "CEK 복원에 필요한 조각 수(Shamir 임계값). lockType이 PASSWORD 또는 QUESTION일 때 필수입니다. " + + "서버는 보관하는 평문 조각 수가 이 값보다 적은지 검증하며, 그렇지 않으면 요청을 거부합니다.", + example = "2", + nullable = true, + ) + @field:Min(2) @field:Max(255) + val keyThreshold: Int? = null, @field:DecimalMin("-90.0") @field:DecimalMax("90.0") @Schema(description = "캡슐을 묻을 위도", example = "37.5") @@ -46,16 +75,17 @@ data class CreateCapsuleRequest( @Schema(description = "잠금 유형", example = "NONE") val lockType: CapsuleLockType, - @Schema(description = "lockType이 PASSWORD일 때의 비밀번호", nullable = true) - val password: String? = null, - + // The lock secret is deliberately absent from this request. It is the one input the server must never + // learn: the client derives the share-wrapping key from it locally, and a server that received it + // could derive the same key, unwrap the share it stores, and reach the threshold on its own. @field:Size(max = 255) - @Schema(description = "lockType이 QUESTION일 때의 질문", example = "우리가 처음 만난 곳은?", nullable = true) + @Schema( + description = "lockType이 QUESTION일 때 열람자에게 보여줄 질문. 정답 자체는 서버로 전송하지 않습니다.", + example = "우리가 처음 만난 곳은?", + nullable = true, + ) val question: String? = null, - @Schema(description = "lockType이 QUESTION일 때의 정답. 대소문자와 앞뒤 공백은 무시됩니다.", nullable = true) - val answer: String? = null, - @Schema(description = "캡슐을 받을 회원 ID 목록. 친구 관계이면서 차단되지 않은 회원이어야 합니다.", example = "[2, 3]") val recipientIds: Set = emptySet(), @@ -63,7 +93,11 @@ data class CreateCapsuleRequest( val mediaIds: Set = emptySet(), ) -@Schema(description = "캡슐 열람 요청. 좌표는 서버에서 PostGIS로 재검증합니다.") +@Schema( + description = "캡슐 열람 요청. 좌표는 서버에서 PostGIS로 재검증합니다.\n\n" + + "잠금 비밀은 보내지 않습니다. 잠금 검증은 서버의 해시 비교가 아니라, 클라이언트가 반환받은 조각을 " + + "자신이 아는 비밀로 풀어내는 과정에서 암호학적으로 이루어집니다. 비밀이 틀리면 GCM 인증 태그 검증이 실패합니다.", +) data class OpenCapsuleRequest( @field:DecimalMin("-90.0") @field:DecimalMax("90.0") @Schema(description = "현재 위도", example = "37.5") @@ -72,12 +106,6 @@ data class OpenCapsuleRequest( @field:DecimalMin("-180.0") @field:DecimalMax("180.0") @Schema(description = "현재 경도", example = "127.0") val longitude: Double, - - @Schema(description = "lockType이 PASSWORD인 캡슐의 비밀번호. 최초 열람 시에만 검증합니다.", nullable = true) - val password: String? = null, - - @Schema(description = "lockType이 QUESTION인 캡슐의 정답. 최초 열람 시에만 검증합니다.", nullable = true) - val answer: String? = null, ) @Schema(description = "방명록 작성 요청") @@ -93,3 +121,22 @@ data class UpdateGuestbookRequest( @Schema(description = "수정할 방명록 내용", example = "다시 다녀갑니다") val content: String, ) + +@Schema(description = "서버에 보관할 CEK 조각 하나") +data class KeyShareRequest( + @field:NotNull + @field:Min(1) @field:Max(255) + @Schema(description = "Shamir x 좌표. 복원에 반드시 필요하므로 조각과 함께 보관됩니다.", example = "1") + val index: Int?, + + @field:NotBlank + @Schema(description = "조각 데이터(Base64)", example = "q83vASNFZ4k=") + val data: String?, + + @field:NotNull + @Schema( + description = "잠금 비밀에서 유도한 키로 감싼 조각인지 여부. true인 조각은 서버가 풀 수 없어 서버의 정족수에 포함되지 않습니다.", + example = "false", + ) + val isWrapped: Boolean?, +) diff --git a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/response/CapsuleResponses.kt b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/response/CapsuleResponses.kt index eb5c8a2..1cfc1d9 100644 --- a/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/response/CapsuleResponses.kt +++ b/src/main/kotlin/team/cklob/mudda/domain/timecapsule/presentation/response/CapsuleResponses.kt @@ -2,6 +2,7 @@ package team.cklob.mudda.domain.timecapsule.presentation.response import io.swagger.v3.oas.annotations.media.Schema import team.cklob.mudda.domain.media.domain.type.MediaType +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 java.time.LocalDateTime @@ -78,11 +79,33 @@ data class CapsuleDetailResponse( @Schema(description = "최종 수정 시각") val updatedAt: LocalDateTime, ) -@Schema(description = "캡슐 열람 응답") +@Schema(description = "CEK 조각 하나") +data class KeyShareResponse( + @Schema(description = "Shamir x 좌표", example = "1") val index: Int, + @Schema(description = "조각 데이터(Base64)") val data: String, + @Schema( + description = "잠금 비밀에서 유도한 키로 감싸진 조각인지 여부. true이면 클라이언트가 비밀번호·정답으로 먼저 풀어야 합니다.", + example = "true", + ) + val isWrapped: Boolean, +) + +@Schema( + description = "캡슐 열람 응답. encryptionMode에 따라 채워지는 필드가 다릅니다 — " + + "SERVER_ENVELOPE이면 content에 평문이 담기고, CLIENT_E2E이면 content는 비어 있고 " + + "contentCipher와 keyShares로 클라이언트가 직접 복호화해야 합니다.", +) data class OpenCapsuleResponse( @Schema(description = "캡슐 ID", example = "1") val capsuleId: Long, @Schema(description = "캡슐 제목", example = "첫 캡슐") val title: String, - @Schema(description = "복호화된 캡슐 내용") val content: String, + @Schema(description = "암호화 모드", example = "CLIENT_E2E") val encryptionMode: CapsuleEncryptionMode, + @Schema(description = "평문 내용. SERVER_ENVELOPE 캡슐에서만 채워집니다.", nullable = true) val content: String?, + @Schema(description = "클라이언트가 복호화해야 할 blob. CLIENT_E2E 캡슐에서만 채워집니다.", nullable = true) + val contentCipher: String?, + @Schema(description = "서버가 보관하던 CEK 조각들. CLIENT_E2E 캡슐에서만 채워집니다.") + val keyShares: List, + @Schema(description = "CEK 복원에 필요한 조각 수. CLIENT_E2E 캡슐에서만 채워집니다.", example = "2", nullable = true) + val keyThreshold: Int?, @Schema(description = "작성자") val writer: WriterResponse, @Schema(description = "첨부 미디어 목록") val media: List, @Schema(description = "최초 열람 시각. 재열람해도 갱신되지 않습니다.") val openedAt: LocalDateTime, diff --git a/src/main/kotlin/team/cklob/mudda/global/crypto/shamir/GaloisField256.kt b/src/main/kotlin/team/cklob/mudda/global/crypto/shamir/GaloisField256.kt new file mode 100644 index 0000000..14eeb99 --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/crypto/shamir/GaloisField256.kt @@ -0,0 +1,63 @@ +package team.cklob.mudda.global.crypto.shamir + +// Arithmetic in GF(2^8) using the AES irreducible polynomial x^8 + x^4 + x^3 + x + 1 (0x11B). +// +// Every byte value is an element of the field, so a secret can be split byte-by-byte with no encoding +// or padding, and addition is XOR. Multiplication and division go through log/exp tables built from the +// generator 0x03: a table lookup is both faster and free of the data-dependent branching that a +// shift-and-xor loop would introduce. +internal object GaloisField256 { + private const val FIELD_SIZE = 256 + private const val IRREDUCIBLE = 0x11B + private const val GENERATOR = 0x03 + + // exp is doubled in length so that a + b (which can reach 508) never needs a modulo before lookup. + private val exp = IntArray(FIELD_SIZE * 2) + private val log = IntArray(FIELD_SIZE) + + init { + var value = 1 + for (i in 0 until FIELD_SIZE - 1) { + exp[i] = value + log[value] = i + value = multiplyRaw(value, GENERATOR) + } + // The cycle has length 255; repeat it so exp[i + 255] == exp[i] for the doubled range. + for (i in FIELD_SIZE - 1 until exp.size) { + exp[i] = exp[i - (FIELD_SIZE - 1)] + } + // log[0] is undefined in the field; multiply/divide handle 0 before ever reading it. + } + + // Only used to bootstrap the tables above. + private fun multiplyRaw(a: Int, b: Int): Int { + var left = a + var right = b + var result = 0 + while (right != 0) { + if (right and 1 != 0) result = result xor left + val carry = left and 0x80 + left = left shl 1 + if (carry != 0) left = left xor IRREDUCIBLE + right = right shr 1 + } + return result and 0xFF + } + + fun add(a: Int, b: Int): Int = a xor b + + // Subtraction is addition: every element is its own additive inverse in characteristic 2. + fun subtract(a: Int, b: Int): Int = a xor b + + fun multiply(a: Int, b: Int): Int { + if (a == 0 || b == 0) return 0 + return exp[log[a] + log[b]] + } + + fun divide(a: Int, b: Int): Int { + require(b != 0) { "Division by zero is undefined in GF(256)." } + if (a == 0) return 0 + // + 255 keeps the index non-negative without a branch on the sign of the difference. + return exp[log[a] - log[b] + (FIELD_SIZE - 1)] + } +} diff --git a/src/main/kotlin/team/cklob/mudda/global/crypto/shamir/ShamirSecretSharing.kt b/src/main/kotlin/team/cklob/mudda/global/crypto/shamir/ShamirSecretSharing.kt new file mode 100644 index 0000000..55d5d3f --- /dev/null +++ b/src/main/kotlin/team/cklob/mudda/global/crypto/shamir/ShamirSecretSharing.kt @@ -0,0 +1,102 @@ +package team.cklob.mudda.global.crypto.shamir + +import java.security.SecureRandom + +// One share of a split secret. `index` is the x-coordinate the polynomials were evaluated at and must be +// kept alongside the bytes -- Lagrange interpolation cannot recover the secret without knowing which x +// each share belongs to. +data class SecretShare(val index: Int, val value: ByteArray) { + init { + require(index in 1..MAX_SHARES) { "A share index must be in 1..$MAX_SHARES." } + require(value.isNotEmpty()) { "A share must carry at least one byte." } + } + + // data class equality on a ByteArray would compare references, which silently breaks set membership + // and assertion messages. + override fun equals(other: Any?): Boolean = + this === other || (other is SecretShare && index == other.index && value.contentEquals(other.value)) + + override fun hashCode(): Int = 31 * index + value.contentHashCode() + + // Never let share bytes reach a log or an assertion message. + override fun toString(): String = "SecretShare(index=$index, value=***)" +} + +const val MAX_SHARES = 255 + +// Shamir's Secret Sharing over GF(256). +// +// The secret is split byte-wise: for each byte a random polynomial of degree threshold-1 is chosen with +// the secret byte as its constant term, then evaluated at x = 1..shareCount. Recovering the constant term +// needs `threshold` points; with any fewer, every possible constant term remains equally likely, so +// threshold-1 shares leak nothing about the secret rather than merely making it hard to guess. +object ShamirSecretSharing { + private val random = SecureRandom() + + fun split(secret: ByteArray, shareCount: Int, threshold: Int): List { + require(secret.isNotEmpty()) { "Cannot split an empty secret." } + require(threshold >= 2) { "A threshold below 2 would store the secret in the clear." } + require(shareCount in threshold..MAX_SHARES) { + "shareCount must be between the threshold and $MAX_SHARES, otherwise the secret is unrecoverable." + } + + val shares = Array(shareCount) { ByteArray(secret.size) } + val coefficients = IntArray(threshold) + + secret.forEachIndexed { byteIndex, secretByte -> + // A fresh polynomial per byte. Reusing one across bytes would leak relationships between them. + coefficients[0] = secretByte.toInt() and 0xFF + for (degree in 1 until threshold) { + coefficients[degree] = random.nextInt(256) + } + for (shareIndex in 0 until shareCount) { + val x = shareIndex + 1 + shares[shareIndex][byteIndex] = evaluate(coefficients, x).toByte() + } + } + + return shares.mapIndexed { shareIndex, value -> SecretShare(shareIndex + 1, value) } + } + + fun combine(shares: List): ByteArray { + require(shares.isNotEmpty()) { "Cannot combine an empty share list." } + val indices = shares.map { it.index } + require(indices.toSet().size == indices.size) { + "Duplicate share indices cannot be interpolated; they describe the same point." + } + val length = shares.first().value.size + require(shares.all { it.value.size == length }) { "Shares of differing lengths did not come from one secret." } + + val secret = ByteArray(length) + for (byteIndex in 0 until length) { + val points = shares.map { it.index to (it.value[byteIndex].toInt() and 0xFF) } + secret[byteIndex] = interpolateAtZero(points).toByte() + } + return secret + } + + // Horner's method, so evaluation stays a single pass over the coefficients. + private fun evaluate(coefficients: IntArray, x: Int): Int { + var result = 0 + for (degree in coefficients.indices.reversed()) { + result = GaloisField256.add(GaloisField256.multiply(result, x), coefficients[degree]) + } + return result + } + + // Lagrange interpolation evaluated at x = 0, which is where the secret sits as the constant term. + private fun interpolateAtZero(points: List>): Int { + var result = 0 + points.forEachIndexed { i, (xi, yi) -> + var basis = 1 + points.forEachIndexed { j, (xj, _) -> + if (i != j) { + // (0 - xj) / (xi - xj); subtraction is XOR, so 0 - xj is simply xj. + basis = GaloisField256.multiply(basis, GaloisField256.divide(xj, GaloisField256.subtract(xi, xj))) + } + } + result = GaloisField256.add(result, GaloisField256.multiply(yi, basis)) + } + return result + } +} diff --git a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt index 1b662ae..7b43898 100644 --- a/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt +++ b/src/main/kotlin/team/cklob/mudda/global/exception/ErrorCode.kt @@ -47,4 +47,6 @@ enum class ErrorCode(val status: HttpStatus, val code: String, val message: Stri CANNOT_REPORT_SELF(HttpStatus.BAD_REQUEST, "R001", "Cannot report yourself."), ALREADY_REPORTED(HttpStatus.CONFLICT, "R002", "You have already reported this target."), REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "R003", "Report target not found."), + INVALID_CAPSULE_ENCRYPTION(HttpStatus.BAD_REQUEST, "T012", "The capsule encryption payload is invalid."), + SERVER_HOLDS_KEY_QUORUM(HttpStatus.BAD_REQUEST, "T013", "The server was given enough key shares to reconstruct the content key."), } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d52c0ac..20a02f6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -75,6 +75,9 @@ media: cron: ${MEDIA_CLEANUP_CRON:0 0 4 * * *} capsule: + key-share-cleanup: + # Removes CEK shares of expired capsules, which can never be opened again. Runs at 04:30 daily. + cron: ${CAPSULE_KEY_SHARE_CLEANUP_CRON:0 30 4 * * *} open-radius-meter: ${CAPSULE_OPEN_RADIUS_METER:100} max-active-per-member: ${CAPSULE_MAX_ACTIVE_PER_MEMBER:100} max-expiration-years: ${CAPSULE_MAX_EXPIRATION_YEARS:10} diff --git a/src/main/resources/db/migration/V8__client_side_capsule_encryption.sql b/src/main/resources/db/migration/V8__client_side_capsule_encryption.sql new file mode 100644 index 0000000..f81b2fe --- /dev/null +++ b/src/main/resources/db/migration/V8__client_side_capsule_encryption.sql @@ -0,0 +1,35 @@ +-- A-1 client-side encryption for locked capsules. +-- +-- Capsules now come in two encryption modes and the column records which one a row uses, so the +-- open path can tell whether it may return a body at all rather than inferring it from lock_type: +-- +-- SERVER_ENVELOPE - lock_type = NONE. The server holds the key and can read the body. This is +-- inherent, not a shortcut: an unlocked capsule's only unlock condition is being at +-- its coordinates, which the server knows, so it could always satisfy it itself. +-- CLIENT_E2E - lock_type = PASSWORD or QUESTION. The client encrypts under a key the server never +-- receives; the server stores an opaque blob and a sub-threshold set of key shares. +-- +-- Existing rows predate client-side encryption, so they take the server-envelope default. +ALTER TABLE tbl_time_capsule + ADD COLUMN encryption_mode VARCHAR(20) NOT NULL DEFAULT 'SERVER_ENVELOPE'; + +-- How many shares reconstruct the content key. Stored so the client knows how many to gather, and so the +-- server can check at creation time that the plaintext shares it was handed stay below this number. +-- Null for SERVER_ENVELOPE capsules, which have no shares. +ALTER TABLE tbl_time_capsule ADD COLUMN key_threshold INT; + +CREATE TABLE tbl_key_share ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + time_capsule_id BIGINT NOT NULL, + share_index INT NOT NULL, + share_data TEXT NOT NULL, + -- true when share_data is itself encrypted under a key derived from the capsule's password or answer. + -- The server stores it but cannot unwrap it, which is what keeps it below the reconstruction + -- threshold: without this share it holds fewer than `threshold` usable shares. + is_wrapped BOOLEAN NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + CONSTRAINT fk_key_share_time_capsule FOREIGN KEY (time_capsule_id) REFERENCES tbl_time_capsule (id), + CONSTRAINT uq_key_share_capsule_index UNIQUE (time_capsule_id, share_index) +); + +CREATE INDEX idx_key_share_time_capsule ON tbl_key_share (time_capsule_id); diff --git a/src/test/kotlin/team/cklob/mudda/domain/timecapsule/application/CapsuleEncryptionPolicyTest.kt b/src/test/kotlin/team/cklob/mudda/domain/timecapsule/application/CapsuleEncryptionPolicyTest.kt new file mode 100644 index 0000000..42c1d3e --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/domain/timecapsule/application/CapsuleEncryptionPolicyTest.kt @@ -0,0 +1,202 @@ +package team.cklob.mudda.domain.timecapsule.application + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +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.domain.type.CapsuleVisibility +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 +import java.time.LocalDateTime +import kotlin.test.assertEquals + +// These rules are the whole guarantee behind CLIENT_E2E. If the policy lets a bad payload through, the +// capsule still reports itself as end-to-end while the server can actually read it, which is worse than +// not offering the mode at all. +class CapsuleEncryptionPolicyTest { + private val policy = CapsuleEncryptionPolicy() + + private fun request( + lockType: CapsuleLockType, + content: String? = null, + contentCipher: String? = null, + keyShares: List? = null, + keyThreshold: Int? = null, + ) = CreateCapsuleRequest( + name = "capsule", content = content, contentCipher = contentCipher, keyShares = keyShares, + keyThreshold = keyThreshold, latitude = 37.5, longitude = 127.0, + openAt = LocalDateTime.now().plusDays(1), visibility = CapsuleVisibility.PUBLIC, lockType = lockType, + question = if (lockType == CapsuleLockType.QUESTION) "q" else null, + ) + + private fun share(index: Int, wrapped: Boolean): KeyShareRequest { + val size = if (wrapped) SHARE_BYTES + 28 else SHARE_BYTES + return KeyShareRequest(index, Base64.getEncoder().encodeToString(ByteArray(size)), wrapped) + } + + private companion object { + const val SHARE_BYTES = 32 + } + + // -------- mode resolution -------- + + @Test fun `an unlocked capsule cannot be end-to-end encrypted`() { + // Not a limitation of the implementation: the only unlock condition is the location, which the + // server stores, so it could always satisfy the condition itself. + assertEquals(CapsuleEncryptionMode.SERVER_ENVELOPE, policy.resolveMode(CapsuleLockType.NONE)) + } + + @Test fun `locked capsules are end-to-end encrypted`() { + assertEquals(CapsuleEncryptionMode.CLIENT_E2E, policy.resolveMode(CapsuleLockType.PASSWORD)) + assertEquals(CapsuleEncryptionMode.CLIENT_E2E, policy.resolveMode(CapsuleLockType.QUESTION)) + } + + // -------- the guarantee -------- + + @Test fun `a payload giving the server a full quorum of plaintext shares is rejected`() { + val request = request( + CapsuleLockType.PASSWORD, contentCipher = "blob", + keyShares = listOf(share(1, wrapped = false), share(2, wrapped = false), share(3, wrapped = true)), + keyThreshold = 2, + ) + + val error = assertThrows { policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) } + + assertEquals(ErrorCode.SERVER_HOLDS_KEY_QUORUM, error.errorCode) + } + + @Test fun `a wrapped share does not count toward the server's quorum`() { + // One plaintext share plus one wrapped share at threshold 2: the server holds only one usable share. + val request = request( + CapsuleLockType.PASSWORD, contentCipher = "blob", + keyShares = listOf(share(1, wrapped = false), share(2, wrapped = true)), + keyThreshold = 2, + ) + + policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) + } + + @Test fun `a payload with no wrapped share is rejected because nobody could open it`() { + val request = request( + CapsuleLockType.PASSWORD, contentCipher = "blob", + keyShares = listOf(share(1, wrapped = false)), keyThreshold = 2, + ) + + val error = assertThrows { policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) } + + assertEquals(ErrorCode.INVALID_CAPSULE_ENCRYPTION, error.errorCode) + } + + @Test fun `an end-to-end capsule may not send plaintext content`() { + val request = request( + CapsuleLockType.PASSWORD, content = "plaintext", contentCipher = "blob", + keyShares = listOf(share(1, wrapped = true)), keyThreshold = 2, + ) + + val error = assertThrows { policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) } + + assertEquals(ErrorCode.INVALID_CAPSULE_ENCRYPTION, error.errorCode) + } + + @Test fun `an end-to-end capsule without a cipher blob is rejected`() { + val request = request(CapsuleLockType.PASSWORD, keyShares = listOf(share(1, wrapped = true)), keyThreshold = 2) + + assertThrows { policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) } + } + + @Test fun `duplicate share indices are rejected`() { + val request = request( + CapsuleLockType.PASSWORD, contentCipher = "blob", + keyShares = listOf(share(1, wrapped = false), share(1, wrapped = true)), keyThreshold = 2, + ) + + assertThrows { policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) } + } + + // -------- server envelope -------- + + @Test fun `an unlocked capsule requires plaintext content`() { + val error = assertThrows { + policy.validate(request(CapsuleLockType.NONE), CapsuleEncryptionMode.SERVER_ENVELOPE) + } + + assertEquals(ErrorCode.INVALID_CAPSULE_ENCRYPTION, error.errorCode) + } + + // Silently dropping the key material would leave the client thinking it made an end-to-end capsule. + @Test fun `an unlocked capsule carrying key material is rejected rather than ignored`() { + val request = request( + CapsuleLockType.NONE, content = "plaintext", + keyShares = listOf(share(1, wrapped = true)), keyThreshold = 2, + ) + + val error = assertThrows { policy.validate(request, CapsuleEncryptionMode.SERVER_ENVELOPE) } + + assertEquals(ErrorCode.INVALID_CAPSULE_ENCRYPTION, error.errorCode) + } + + @Test fun `a plain unlocked capsule passes`() { + policy.validate(request(CapsuleLockType.NONE, content = "plaintext"), CapsuleEncryptionMode.SERVER_ENVELOPE) + } + + // -------- lock secret must never reach the server -------- + + @Test fun `a question capsule requires the question text`() { + val request = request( + CapsuleLockType.QUESTION, contentCipher = "blob", + keyShares = listOf(share(1, wrapped = false), share(2, wrapped = true)), keyThreshold = 2, + ).copy(question = null) + + val error = assertThrows { policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) } + + assertEquals(ErrorCode.INVALID_CAPSULE_ENCRYPTION, error.errorCode) + } + + @Test fun `a password capsule must not carry question text`() { + val request = request( + CapsuleLockType.PASSWORD, contentCipher = "blob", + keyShares = listOf(share(1, wrapped = false), share(2, wrapped = true)), keyThreshold = 2, + ).copy(question = "왜?") + + assertThrows { policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) } + } + + // -------- wrapped share shape -------- + + // The realistic failure this catches: a client bug that sends raw shares but labels them wrapped, + // which would slip past the quorum check and leave the server able to read the capsule. + @Test fun `a raw share labelled as wrapped is rejected by its length`() { + val raw = Base64.getEncoder().encodeToString(ByteArray(SHARE_BYTES)) + val request = request( + CapsuleLockType.PASSWORD, contentCipher = "blob", + keyShares = listOf(share(1, wrapped = false), KeyShareRequest(2, raw, true)), keyThreshold = 2, + ) + + val error = assertThrows { policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) } + + assertEquals(ErrorCode.INVALID_CAPSULE_ENCRYPTION, error.errorCode) + } + + @Test fun `a share that is not valid base64 is rejected`() { + val request = request( + CapsuleLockType.PASSWORD, contentCipher = "blob", + keyShares = listOf(share(1, wrapped = false), KeyShareRequest(2, "not base64!!", true)), keyThreshold = 2, + ) + + assertThrows { policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) } + } + + // Holding zero usable shares is the strongest case for the server, and there is no plaintext baseline + // to measure the wrapped ones against, so the shape check steps aside. + @Test fun `an all-wrapped payload is accepted`() { + val request = request( + CapsuleLockType.PASSWORD, contentCipher = "blob", + keyShares = listOf(share(1, wrapped = true), share(2, wrapped = true)), keyThreshold = 2, + ) + + policy.validate(request, CapsuleEncryptionMode.CLIENT_E2E) + } +} diff --git a/src/test/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/OpenCapsuleServiceTest.kt b/src/test/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/OpenCapsuleServiceTest.kt index 2b93e43..48f76e5 100644 --- a/src/test/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/OpenCapsuleServiceTest.kt +++ b/src/test/kotlin/team/cklob/mudda/domain/timecapsule/application/impl/OpenCapsuleServiceTest.kt @@ -7,7 +7,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.locationtech.jts.geom.Coordinate import org.locationtech.jts.geom.GeometryFactory -import org.springframework.security.crypto.password.PasswordEncoder import team.cklob.mudda.domain.media.application.MediaStorage import team.cklob.mudda.domain.media.domain.repository.MediaRepository import team.cklob.mudda.domain.member.domain.entity.Member @@ -23,7 +22,10 @@ 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.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.entity.KeyShare +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 @@ -40,14 +42,14 @@ class OpenCapsuleServiceTest { private val memberRepository = mockk() private val mediaRepository = mockk() private val mediaStorage = mockk() - private val passwordEncoder = mockk() private val accessPolicy = mockk() private val notificationPublisher = mockk(relaxed = true) private val feedBroadcaster = mockk(relaxed = true) + private val keyShareRepository = mockk(relaxed = true) private val service = OpenCapsuleService( capsuleRepository, openRepository, recipientRepository, memberRepository, - mediaRepository, mediaStorage, passwordEncoder, accessPolicy, - notificationPublisher, feedBroadcaster, + mediaRepository, mediaStorage, accessPolicy, + notificationPublisher, feedBroadcaster, keyShareRepository, ) private val member = Member( @@ -56,7 +58,7 @@ class OpenCapsuleServiceTest { ) private val capsule = TimeCapsule( member = member, name = "capsule", content = "secret", visibility = CapsuleVisibility.PRIVATE, - lockType = CapsuleLockType.PASSWORD, passwordHash = "hash", + lockType = CapsuleLockType.PASSWORD, location = GeometryFactory().createPoint(Coordinate(127.0, 37.5)), openRadiusMeter = 100, openAt = LocalDateTime.now().minusDays(1), id = 1, ) @@ -76,7 +78,6 @@ class OpenCapsuleServiceTest { assertEquals("secret", response.content) assertEquals(openedAt, response.openedAt) verify(exactly = 1) { capsuleRepository.isWithinOpeningRadius(1, 37.5, 127.0) } - verify(exactly = 0) { passwordEncoder.matches(any(), any()) } } @Test @@ -116,13 +117,12 @@ class OpenCapsuleServiceTest { every { accessPolicy.requireAccessible(capsule, 8, any()) } returns Unit every { capsuleRepository.isWithinOpeningRadius(1, 37.5, 127.0) } returns true every { openRepository.findByTimeCapsuleIdAndMemberId(1, 8) } returns Optional.empty() - every { passwordEncoder.matches("pw", "hash") } returns true every { memberRepository.findById(8) } returns Optional.of(opener) every { openRepository.save(any()) } answers { CapsuleOpen(capsule, opener, LocalDateTime.now(), id = 2) } every { recipientRepository.findByTimeCapsuleIdAndMemberId(1, 8) } returns Optional.empty() every { mediaRepository.findAllByTimeCapsuleId(1) } returns emptyList() - service.execute(8, 1, OpenCapsuleRequest(37.5, 127.0, password = "pw")) + service.execute(8, 1, OpenCapsuleRequest(37.5, 127.0)) verify(exactly = 1) { notificationPublisher.publish(member, NotificationType.CAPSULE_OPENED, any(), any(), 1L, NotificationTargetType.CAPSULE) @@ -136,14 +136,65 @@ class OpenCapsuleServiceTest { every { accessPolicy.requireAccessible(capsule, 7, any()) } returns Unit every { capsuleRepository.isWithinOpeningRadius(1, 37.5, 127.0) } returns true every { openRepository.findByTimeCapsuleIdAndMemberId(1, 7) } returns Optional.empty() - every { passwordEncoder.matches("pw", "hash") } returns true every { memberRepository.findById(7) } returns Optional.of(member) every { openRepository.save(any()) } answers { CapsuleOpen(capsule, member, LocalDateTime.now(), id = 3) } every { recipientRepository.findByTimeCapsuleIdAndMemberId(1, 7) } returns Optional.empty() every { mediaRepository.findAllByTimeCapsuleId(1) } returns emptyList() - service.execute(7, 1, OpenCapsuleRequest(37.5, 127.0, password = "pw")) + service.execute(7, 1, OpenCapsuleRequest(37.5, 127.0)) verify(exactly = 0) { notificationPublisher.publish(any(), any(), any(), any(), any(), any()) } } + + // -------- client-side encryption -------- + + private val e2eCapsule = TimeCapsule( + member = member, name = "e2e", content = "CLIENT-CIPHERTEXT", visibility = CapsuleVisibility.PRIVATE, + lockType = CapsuleLockType.PASSWORD, + location = GeometryFactory().createPoint(Coordinate(127.0, 37.5)), openRadiusMeter = 100, + openAt = LocalDateTime.now().minusDays(1), + encryptionMode = CapsuleEncryptionMode.CLIENT_E2E, keyThreshold = 2, id = 2, + ) + + // The server has no key for a CLIENT_E2E capsule, so `content` must stay empty; putting the stored blob + // there would tell the client it received a decrypted body. + @Test fun `opening an end-to-end capsule returns shares and never a plaintext content field`() { + val opened = CapsuleOpen(e2eCapsule, member, LocalDateTime.now().minusHours(1), id = 4) + every { capsuleRepository.findByIdAndIsDeletedFalseForUpdate(2) } returns Optional.of(e2eCapsule) + every { accessPolicy.requireAccessible(e2eCapsule, 7, any()) } returns Unit + every { capsuleRepository.isWithinOpeningRadius(2, 37.5, 127.0) } returns true + every { openRepository.findByTimeCapsuleIdAndMemberId(2, 7) } returns Optional.of(opened) + every { mediaRepository.findAllByTimeCapsuleId(2) } returns emptyList() + every { keyShareRepository.findAllByTimeCapsuleIdOrderByShareIndex(2) } returns listOf( + KeyShare(e2eCapsule, 1, "server-share", isWrapped = false), + KeyShare(e2eCapsule, 2, "wrapped-share", isWrapped = true), + ) + + val response = service.execute(7, 2, OpenCapsuleRequest(37.5, 127.0)) + + assertEquals(null, response.content) + assertEquals("CLIENT-CIPHERTEXT", response.contentCipher) + assertEquals(CapsuleEncryptionMode.CLIENT_E2E, response.encryptionMode) + assertEquals(2, response.keyThreshold) + assertEquals(listOf(1, 2), response.keyShares.map { it.index }) + assertEquals(listOf(false, true), response.keyShares.map { it.isWrapped }) + } + + // The server-envelope path is unchanged: it still returns the decrypted body and carries no shares. + @Test fun `opening a server envelope capsule returns content and no shares`() { + val opened = CapsuleOpen(capsule, member, LocalDateTime.now().minusHours(1), id = 5) + every { capsuleRepository.findByIdAndIsDeletedFalseForUpdate(1) } returns Optional.of(capsule) + every { accessPolicy.requireAccessible(capsule, 7, any()) } returns Unit + every { capsuleRepository.isWithinOpeningRadius(1, 37.5, 127.0) } returns true + every { openRepository.findByTimeCapsuleIdAndMemberId(1, 7) } returns Optional.of(opened) + every { mediaRepository.findAllByTimeCapsuleId(1) } returns emptyList() + + val response = service.execute(7, 1, OpenCapsuleRequest(37.5, 127.0)) + + assertEquals("secret", response.content) + assertEquals(null, response.contentCipher) + assertEquals(CapsuleEncryptionMode.SERVER_ENVELOPE, response.encryptionMode) + assertEquals(emptyList(), response.keyShares) + verify(exactly = 0) { keyShareRepository.findAllByTimeCapsuleIdOrderByShareIndex(any()) } + } } diff --git a/src/test/kotlin/team/cklob/mudda/global/crypto/shamir/CapsuleKeyRoundTripTest.kt b/src/test/kotlin/team/cklob/mudda/global/crypto/shamir/CapsuleKeyRoundTripTest.kt new file mode 100644 index 0000000..ff9cf72 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/global/crypto/shamir/CapsuleKeyRoundTripTest.kt @@ -0,0 +1,92 @@ +package team.cklob.mudda.global.crypto.shamir + +import org.junit.jupiter.api.Test +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +// Walks the whole CLIENT_E2E scheme end to end from the client's point of view: encrypt under a CEK, +// split the CEK, wrap one share under the lock secret, hand the server what it is allowed to hold, and +// verify both that a legitimate opener succeeds and that the server's own holdings do not. +// +// This is what makes the design claim testable rather than aspirational. +class CapsuleKeyRoundTripTest { + private val random = SecureRandom() + + private fun aesGcm(mode: Int, key: ByteArray, nonce: ByteArray, input: ByteArray): ByteArray = + Cipher.getInstance("AES/GCM/NoPadding").run { + init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(128, nonce)) + doFinal(input) + } + + // Stands in for the client's KDF over the capsule's password or answer. The real client should use a + // slow, salted KDF; the property under test here is only that the server never learns this value. + private fun keyFromLockSecret(secret: String): ByteArray = + java.security.MessageDigest.getInstance("SHA-256").digest(secret.toByteArray()) + + @Test fun `an opener who knows the lock secret recovers the body`() { + val body = "10년 뒤의 나에게" + val cek = ByteArray(32).also(random::nextBytes) + val nonce = ByteArray(12).also(random::nextBytes) + val blob = aesGcm(Cipher.ENCRYPT_MODE, cek, nonce, body.toByteArray()) + + val shares = ShamirSecretSharing.split(cek, shareCount = 2, threshold = 2) + val serverShare = shares[0] + val wrappedNonce = ByteArray(12).also(random::nextBytes) + val wrappedShare = aesGcm(Cipher.ENCRYPT_MODE, keyFromLockSecret("정답"), wrappedNonce, shares[1].value) + + // --- what the server stores --- + val storedBlob = Base64.getEncoder().encodeToString(nonce + blob) + val storedShares = listOf(serverShare, SecretShare(2, wrappedNonce + wrappedShare)) + + // --- what an opener at the location, knowing the answer, does --- + val returned = storedShares + val unwrapped = returned[1].value.let { + aesGcm(Cipher.DECRYPT_MODE, keyFromLockSecret("정답"), it.copyOfRange(0, 12), it.copyOfRange(12, it.size)) + } + val recoveredCek = ShamirSecretSharing.combine(listOf(returned[0], SecretShare(2, unwrapped))) + + val raw = Base64.getDecoder().decode(storedBlob) + val recovered = aesGcm(Cipher.DECRYPT_MODE, recoveredCek, raw.copyOfRange(0, 12), raw.copyOfRange(12, raw.size)) + + assertEquals(body, String(recovered)) + } + + // The claim the whole design rests on. The server holds one plaintext share and one blob it has no key + // for, which is below the threshold of 2. + @Test fun `the server's own shares do not reconstruct the key`() { + val cek = ByteArray(32).also(random::nextBytes) + val shares = ShamirSecretSharing.split(cek, shareCount = 2, threshold = 2) + val wrappedNonce = ByteArray(12).also(random::nextBytes) + val wrapped = aesGcm(Cipher.ENCRYPT_MODE, keyFromLockSecret("정답"), wrappedNonce, shares[1].value) + + // Everything the server has: one usable share, plus ciphertext it cannot open. + val serverUsable = listOf(shares[0]) + assertEquals(1, serverUsable.size, "the server must hold fewer shares than the threshold") + + // Treating the wrapped bytes as if they were a share -- the best the server can do without the + // lock secret -- yields something other than the key. + val naive = ShamirSecretSharing.combine(listOf(shares[0], SecretShare(2, (wrappedNonce + wrapped).copyOfRange(0, 32)))) + assertFalse(naive.contentEquals(cek), "the server reconstructed the key from what it stores") + } + + @Test fun `a wrong answer fails loudly instead of yielding a wrong body`() { + val cek = ByteArray(32).also(random::nextBytes) + val shares = ShamirSecretSharing.split(cek, shareCount = 2, threshold = 2) + val nonce = ByteArray(12).also(random::nextBytes) + val wrapped = aesGcm(Cipher.ENCRYPT_MODE, keyFromLockSecret("정답"), nonce, shares[1].value) + + // GCM authenticates, so an unwrap under the wrong key is detected rather than returning garbage + // that would later surface as an unreadable body. + val failed = runCatching { + aesGcm(Cipher.DECRYPT_MODE, keyFromLockSecret("오답"), nonce, wrapped) + } + + assertTrue(failed.isFailure, "unwrapping with a wrong answer must fail the GCM tag check") + } +} diff --git a/src/test/kotlin/team/cklob/mudda/global/crypto/shamir/GaloisField256Test.kt b/src/test/kotlin/team/cklob/mudda/global/crypto/shamir/GaloisField256Test.kt new file mode 100644 index 0000000..dcd9fbe --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/global/crypto/shamir/GaloisField256Test.kt @@ -0,0 +1,73 @@ +package team.cklob.mudda.global.crypto.shamir + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals + +// The field laws are what make Lagrange interpolation recover the right constant term; if any of them +// break, split/combine fails in ways that look like corrupted data rather than a math bug. +class GaloisField256Test { + @Test fun `addition is xor and is its own inverse`() { + for (a in 0..255) { + for (b in 0..255) { + assertEquals(a xor b, GaloisField256.add(a, b)) + assertEquals(a, GaloisField256.subtract(GaloisField256.add(a, b), b)) + } + } + } + + @Test fun `one is the multiplicative identity and zero annihilates`() { + for (a in 0..255) { + assertEquals(a, GaloisField256.multiply(a, 1)) + assertEquals(0, GaloisField256.multiply(a, 0)) + assertEquals(0, GaloisField256.multiply(0, a)) + } + } + + @Test fun `multiplication is commutative and stays inside the field`() { + for (a in 0..255) { + for (b in 0..255) { + val product = GaloisField256.multiply(a, b) + assertEquals(GaloisField256.multiply(b, a), product) + assertEquals(product, product and 0xFF, "a product escaped the byte range") + } + } + } + + @Test fun `division undoes multiplication for every non-zero divisor`() { + for (a in 0..255) { + for (b in 1..255) { + assertEquals(a, GaloisField256.divide(GaloisField256.multiply(a, b), b)) + } + } + } + + @Test fun `every non-zero element has a multiplicative inverse`() { + for (a in 1..255) { + assertEquals(1, GaloisField256.multiply(a, GaloisField256.divide(1, a))) + } + } + + @Test fun `multiplication is associative and distributes over addition`() { + // A sampled sweep: the full 16.7M triple loop would dominate the suite's runtime. + val values = (0..255 step 7).toList() + for (a in values) { + for (b in values) { + for (c in values) { + assertEquals( + GaloisField256.multiply(GaloisField256.multiply(a, b), c), + GaloisField256.multiply(a, GaloisField256.multiply(b, c)), + ) + assertEquals( + GaloisField256.multiply(a, GaloisField256.add(b, c)), + GaloisField256.add(GaloisField256.multiply(a, b), GaloisField256.multiply(a, c)), + ) + } + } + } + } + + @Test fun `dividing by zero is rejected rather than returning a wrong element`() { + assertThrows { GaloisField256.divide(5, 0) } + } +} diff --git a/src/test/kotlin/team/cklob/mudda/global/crypto/shamir/ShamirSecretSharingTest.kt b/src/test/kotlin/team/cklob/mudda/global/crypto/shamir/ShamirSecretSharingTest.kt new file mode 100644 index 0000000..9b003a3 --- /dev/null +++ b/src/test/kotlin/team/cklob/mudda/global/crypto/shamir/ShamirSecretSharingTest.kt @@ -0,0 +1,152 @@ +package team.cklob.mudda.global.crypto.shamir + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.security.SecureRandom +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +// A bug here is unrecoverable in production: a capsule whose key cannot be reassembled is sealed forever, +// with no server-side copy to fall back on. The properties below are asserted rather than sampled where +// the search space allows it. +class ShamirSecretSharingTest { + private val random = SecureRandom() + + private fun secret(size: Int = 32) = ByteArray(size).also(random::nextBytes) + + @Test fun `any subset of exactly the threshold size recovers the secret`() { + val secret = secret() + val shares = ShamirSecretSharing.split(secret, shareCount = 5, threshold = 3) + + // All 10 three-element subsets must work, not just the first three shares. + for (i in 0 until 5) { + for (j in i + 1 until 5) { + for (k in j + 1 until 5) { + val subset = listOf(shares[i], shares[j], shares[k]) + assertContentEquals(secret, ShamirSecretSharing.combine(subset), "subset $i,$j,$k failed") + } + } + } + } + + @Test fun `more shares than the threshold still recover the secret`() { + val secret = secret() + val shares = ShamirSecretSharing.split(secret, shareCount = 5, threshold = 3) + + assertContentEquals(secret, ShamirSecretSharing.combine(shares)) + } + + // The security property, not merely a difficulty property: with threshold-1 shares every candidate + // secret is still equally likely. Combining a short subset must therefore not return the secret. + @Test fun `threshold minus one shares do not reveal the secret`() { + val secret = secret() + val shares = ShamirSecretSharing.split(secret, shareCount = 5, threshold = 3) + + repeat(200) { + val subset = shares.shuffled().take(2) + assertFalse( + ShamirSecretSharing.combine(subset).contentEquals(secret), + "two of three shares reconstructed the secret", + ) + } + } + + // Sharpens the previous test: for a one-byte secret with a 2-of-n split, a single share is consistent + // with every one of the 256 possible secrets. Pairing it with each possible second share must produce + // all 256 values exactly once -- the signature of information-theoretic secrecy. + @Test fun `one share of a two-of-n split is consistent with every possible secret`() { + val shares = ShamirSecretSharing.split(byteArrayOf(0x42), shareCount = 2, threshold = 2) + val known = shares.first() + + val reachable = (0..255).map { candidate -> + ShamirSecretSharing.combine(listOf(known, SecretShare(2, byteArrayOf(candidate.toByte()))))[0].toInt() and 0xFF + } + + assertEquals(256, reachable.toSet().size, "a single share narrowed the secret down") + } + + @Test fun `the documented two-of-three and three-of-five configurations round-trip`() { + for ((shareCount, threshold) in listOf(3 to 2, 5 to 3)) { + val secret = secret() + val shares = ShamirSecretSharing.split(secret, shareCount, threshold) + + assertEquals(shareCount, shares.size) + assertContentEquals(secret, ShamirSecretSharing.combine(shares.take(threshold))) + } + } + + @Test fun `secrets containing zero and full bytes survive the round trip`() { + // 0x00 and 0xFF exercise the branches where the log table is not defined and where it saturates. + val secret = byteArrayOf(0, 0, 0, -1, -1, 0, 127, -128) + val shares = ShamirSecretSharing.split(secret, shareCount = 4, threshold = 2) + + assertContentEquals(secret, ShamirSecretSharing.combine(shares.take(2))) + } + + @Test fun `a 256 bit key round-trips at the maximum share count`() { + val secret = secret(32) + val shares = ShamirSecretSharing.split(secret, shareCount = MAX_SHARES, threshold = 2) + + assertEquals(MAX_SHARES, shares.size) + assertContentEquals(secret, ShamirSecretSharing.combine(listOf(shares.first(), shares.last()))) + } + + @Test fun `splitting is randomised so two splits of one secret differ`() { + val secret = secret() + + val first = ShamirSecretSharing.split(secret, shareCount = 3, threshold = 2) + val second = ShamirSecretSharing.split(secret, shareCount = 3, threshold = 2) + + assertFalse(first[0].value.contentEquals(second[0].value), "share bytes repeated across splits") + assertContentEquals(secret, ShamirSecretSharing.combine(second.take(2))) + } + + @Test fun `share indices start at one so no share sits on the secret itself`() { + val shares = ShamirSecretSharing.split(secret(), shareCount = 3, threshold = 2) + + assertEquals(listOf(1, 2, 3), shares.map { it.index }) + assertTrue(shares.none { it.index == 0 }, "x=0 is where the secret lives and must never be a share") + } + + @Test fun `a threshold below two is rejected because it would store the secret in the clear`() { + assertThrows { ShamirSecretSharing.split(secret(), shareCount = 3, threshold = 1) } + } + + @Test fun `a share count below the threshold is rejected as unrecoverable`() { + assertThrows { ShamirSecretSharing.split(secret(), shareCount = 2, threshold = 3) } + } + + @Test fun `an empty secret is rejected`() { + assertThrows { ShamirSecretSharing.split(ByteArray(0), shareCount = 3, threshold = 2) } + } + + @Test fun `duplicate share indices are rejected instead of returning garbage`() { + val shares = ShamirSecretSharing.split(secret(), shareCount = 3, threshold = 2) + val duplicated = listOf(shares[0], shares[0]) + + assertThrows { ShamirSecretSharing.combine(duplicated) } + } + + @Test fun `shares of differing lengths are rejected`() { + val shares = ShamirSecretSharing.split(secret(), shareCount = 3, threshold = 2) + val mismatched = listOf(shares[0], SecretShare(2, ByteArray(4))) + + assertThrows { ShamirSecretSharing.combine(mismatched) } + } + + @Test fun `share equality compares bytes rather than references`() { + val first = SecretShare(1, byteArrayOf(1, 2, 3)) + val second = SecretShare(1, byteArrayOf(1, 2, 3)) + + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + } + + @Test fun `a share never prints its bytes`() { + val share = SecretShare(1, byteArrayOf(9, 9, 9)) + + assertFalse(share.toString().contains("9"), "share bytes leaked through toString") + } +}