From 61ef0aa1ac56afb15d750e7dfae6c6bfdb52bf02 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 14:12:12 +0200 Subject: [PATCH] feat(admin): implement E2EE encryption for admin test messages using recipient public keys --- .../bollwerk/server/routes/MessageRoutes.kt | 38 +++++++++++++++-- .../server/service/AdminMessageService.kt | 41 +++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 server/src/main/kotlin/de/bollwerk/server/service/AdminMessageService.kt diff --git a/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt b/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt index 57845fc..518e4fa 100644 --- a/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt +++ b/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt @@ -4,6 +4,7 @@ import de.bollwerk.server.model.ErrorResponse import de.bollwerk.server.repository.MessageRepository import de.bollwerk.server.repository.UserRepository import de.bollwerk.server.security.UserPrincipal +import de.bollwerk.server.service.AdminMessageService import de.bollwerk.server.websocket.WebSocketManager import de.bollwerk.shared.model.SendMessageResponse import io.ktor.http.* @@ -27,7 +28,8 @@ internal data class SendMessageRequest( internal fun Route.messageRoutes( messageRepository: MessageRepository, userRepository: UserRepository, - wsManager: WebSocketManager + wsManager: WebSocketManager, + adminMessageService: AdminMessageService ) { route("/api/messages") { post { @@ -111,6 +113,22 @@ internal fun Route.messageRoutes( ) } + // Additional security: verify admin token header + val providedToken = call.request.headers["X-Admin-Token"] + val expectedToken = System.getenv("ADMIN_MESSAGE_TOKEN") + if (expectedToken.isNullOrBlank()) { + return@post call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse(status = 500, message = "Server not configured for admin messages") + ) + } + if (providedToken != expectedToken) { + return@post call.respond( + HttpStatusCode.Forbidden, + ErrorResponse(status = 403, message = "Invalid admin token") + ) + } + val request = call.receive() if (request.body.isBlank()) { call.respond( @@ -128,14 +146,26 @@ internal fun Route.messageRoutes( } val msgId = request.id ?: UUID.randomUUID().toString() - // Wrap body with [PLAINTEXT] marker so app doesn't try to decrypt - val plaintext = "[PLAINTEXT] ${request.body}" + + // Encrypt the message with the recipient's public key + val encryptedBody = try { + adminMessageService.encryptMessage(request.receiverId, request.body) + } catch (e: Exception) { + return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse( + status = 400, + message = "Encryption failed: ${e.message}" + ) + ) + } + val message = messageRepository.save( id = msgId, senderId = principal.userId, senderUsername = principal.username, receiverId = request.receiverId, - body = plaintext, + body = encryptedBody, sentAt = request.sentAt ) wsManager.notifyNewMessage(request.receiverId, message) diff --git a/server/src/main/kotlin/de/bollwerk/server/service/AdminMessageService.kt b/server/src/main/kotlin/de/bollwerk/server/service/AdminMessageService.kt new file mode 100644 index 0000000..feb8080 --- /dev/null +++ b/server/src/main/kotlin/de/bollwerk/server/service/AdminMessageService.kt @@ -0,0 +1,41 @@ +package de.bollwerk.server.service + +import com.google.crypto.tink.HybridEncrypt +import com.google.crypto.tink.JsonKeysetReader +import com.google.crypto.tink.hybrid.HybridConfig +import de.bollwerk.server.repository.UserRepository +import java.util.Base64 + +internal class AdminMessageService( + private val userRepository: UserRepository +) { + + init { + HybridConfig.register() + } + + fun encryptMessage(recipientUserId: String, plaintextBody: String): String { + val publicKeyBase64 = userRepository.getPublicKey(recipientUserId) + ?: throw IllegalArgumentException("User has no E2EE public key: $recipientUserId") + + return encryptWithPublicKey(publicKeyBase64, plaintextBody) + } + + private fun encryptWithPublicKey(publicKeyBase64: String, plaintext: String): String { + val publicKeysetJson = String( + Base64.getDecoder().decode(publicKeyBase64), + Charsets.UTF_8 + ) + val publicHandle = JsonKeysetReader.withString(publicKeysetJson).read() + + // Use reflection to call getPrimitive since it may not be directly accessible + val getPrimitiveMethod = publicHandle::class.java.getMethod("getPrimitive", Class::class.java) + @Suppress("UNCHECKED_CAST") + val hybridEncrypt = getPrimitiveMethod.invoke(publicHandle, HybridEncrypt::class.java) as HybridEncrypt + + val ciphertext = hybridEncrypt.encrypt(plaintext.toByteArray(Charsets.UTF_8), null) + return Base64.getEncoder().encodeToString(ciphertext) + } +} + +