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