feat(admin): implement E2EE encryption for admin test messages using recipient public keys
This commit is contained in:
parent
ff80293c7a
commit
61ef0aa1ac
2 changed files with 75 additions and 4 deletions
|
|
@ -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<SendMessageRequest>()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in a new issue