feat(admin): implement E2EE encryption for admin test messages using recipient public keys

This commit is contained in:
Jens Reinemann 2026-05-18 14:12:12 +02:00
parent ff80293c7a
commit 61ef0aa1ac
2 changed files with 75 additions and 4 deletions

View file

@ -4,6 +4,7 @@ import de.bollwerk.server.model.ErrorResponse
import de.bollwerk.server.repository.MessageRepository import de.bollwerk.server.repository.MessageRepository
import de.bollwerk.server.repository.UserRepository import de.bollwerk.server.repository.UserRepository
import de.bollwerk.server.security.UserPrincipal import de.bollwerk.server.security.UserPrincipal
import de.bollwerk.server.service.AdminMessageService
import de.bollwerk.server.websocket.WebSocketManager import de.bollwerk.server.websocket.WebSocketManager
import de.bollwerk.shared.model.SendMessageResponse import de.bollwerk.shared.model.SendMessageResponse
import io.ktor.http.* import io.ktor.http.*
@ -27,7 +28,8 @@ internal data class SendMessageRequest(
internal fun Route.messageRoutes( internal fun Route.messageRoutes(
messageRepository: MessageRepository, messageRepository: MessageRepository,
userRepository: UserRepository, userRepository: UserRepository,
wsManager: WebSocketManager wsManager: WebSocketManager,
adminMessageService: AdminMessageService
) { ) {
route("/api/messages") { route("/api/messages") {
post { 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<SendMessageRequest>() val request = call.receive<SendMessageRequest>()
if (request.body.isBlank()) { if (request.body.isBlank()) {
call.respond( call.respond(
@ -128,14 +146,26 @@ internal fun Route.messageRoutes(
} }
val msgId = request.id ?: UUID.randomUUID().toString() 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( val message = messageRepository.save(
id = msgId, id = msgId,
senderId = principal.userId, senderId = principal.userId,
senderUsername = principal.username, senderUsername = principal.username,
receiverId = request.receiverId, receiverId = request.receiverId,
body = plaintext, body = encryptedBody,
sentAt = request.sentAt sentAt = request.sentAt
) )
wsManager.notifyNewMessage(request.receiverId, message) wsManager.notifyNewMessage(request.receiverId, message)

View file

@ -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)
}
}