diff --git a/.github/skills/admin-message-e2ee/SKILL.md b/.github/skills/admin-message-e2ee/SKILL.md new file mode 100644 index 0000000..f711da6 --- /dev/null +++ b/.github/skills/admin-message-e2ee/SKILL.md @@ -0,0 +1,94 @@ +--- +name: admin-message-e2ee +description: "Admin-Kommunikation fuer E2EE-Testnachrichten auf dem Bollwerk-Server. Nutze diesen Skill fuer Generaltests mit POST /api/admin/send-message per Bearer-Admin-Token (ohne Admin-Login). Trigger: 'admin message', 'send-message', 'e2ee test', 'generaltest nachrichten', 'bob alice nachrichten als admin'." +--- + +# Skill: Admin Message E2EE Generaltest + +Dieser Skill beschreibt den sicheren Testablauf fuer den Admin-Message-Dienst auf dem Server. + +Ziel: + +- E2EE-Nachrichten serverseitig erzeugen und an reale User zustellen +- Endpunkt korrekt mit Bearer-Admin-Token verwenden +- Zustellung anschliessend ueber Conversation-API verifizieren + +## Technischer Hintergrund + +Der Endpunkt ist: + +- `POST /api/admin/send-message` + +Pflichtbedingungen: + +- `Authorization: Bearer ` + +Wichtig: + +- Kein Admin-Login erforderlich. +- Absender wird explizit per `senderUsername` uebergeben (z. B. `bob` oder `alice`). +- Empfaenger kann per `receiverUsername` (z. B. `admin`) adressiert werden. +- Dieser Endpunkt eignet sich fuer Admin-initiierte Testnachrichten im E2EE-Format. + +## Vorbedingungen + +1. Server erreichbar (z. B. `https://bollwerk.online`) +2. Bearer-Admin-Token (`BOLLWERK_ADMIN_TOKEN`) vorhanden + +## API-Schritte + +1. Health pruefen + +```powershell +Invoke-WebRequest -Uri "https://bollwerk.online/api/health" -UseBasicParsing +``` + +2. Userliste holen (IDs fuer admin/bob/alice) + +```powershell +$users = Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/admin/users" -Headers @{ Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN" } +``` + +3. E2EE-Admin-Nachricht senden + +```powershell +$headers = @{ + Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN" + "Content-Type" = "application/json" +} +$payload = @{ + senderUsername = "bob" + receiverUsername = "admin" + body = "Testnachricht" + sentAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +} | ConvertTo-Json -Compress +Invoke-RestMethod -Method POST -Uri "https://bollwerk.online/api/admin/send-message" -Headers $headers -Body $payload +``` + +4. Zustellung verifizieren + +```powershell +Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/messages/" -Headers @{ Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN" } +``` + +## Erwartete Fehlerbilder + +- `401 Unauthorized`: Bearer-Token fehlt +- `403 Forbidden`: Bearer-Token stimmt nicht +- `404 Sender not found`: `senderUsername` existiert nicht +- `400 Encryption failed`: Empfaenger hat keinen gueltigen E2EE Public Key +- `404 Receiver not found`: falsche `receiverId` + +## Generaltest-Standardfall + +Wenn User explizit "1x Bob, mehrere x Alice" wuenscht und Admin-Route genutzt wird: + +- Sende 1 Nachricht an Admin-Konversation mit Prefix `[bob] ...` +- Sende 3 Nachrichten an Admin-Konversation mit `senderUsername = "alice"` +- Dokumentiere klar, welcher `senderUsername` je Request verwendet wurde + +## Security-Hinweise + +- Secrets nie im Chat ausgeben +- Tokens nur maskiert loggen +- Keine Passwoerter fuer diesen Endpunkt erforderlich diff --git a/.github/skills/publish/__pycache__/publish-apk.cpython-313.pyc b/.github/skills/publish/__pycache__/publish-apk.cpython-313.pyc new file mode 100644 index 0000000..e1d4fce Binary files /dev/null and b/.github/skills/publish/__pycache__/publish-apk.cpython-313.pyc differ diff --git a/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt b/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt index 8182b5e..4307369 100644 --- a/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt +++ b/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt @@ -6,6 +6,7 @@ import de.bollwerk.server.repository.MessageRepository import de.bollwerk.server.repository.UserRepository import de.bollwerk.server.routes.adminRoutes import de.bollwerk.server.routes.authRoutes +import de.bollwerk.server.routes.adminMessageRoutes import de.bollwerk.server.routes.inventoryRoutes import de.bollwerk.server.routes.messageRoutes import de.bollwerk.server.routes.userRoutes @@ -95,11 +96,16 @@ internal fun Application.configureRouting( adminRoutes(userRepository, inventoryRepository, backupDir) } rateLimit(RATE_LIMIT_MESSAGES) { - messageRoutes(messageRepository, userRepository, wsManager, adminMessageService) + messageRoutes(messageRepository, userRepository, wsManager) } userRoutes(userRepository, wsManager) } + // Token-only endpoint for E2EE general test messages + rateLimit(RATE_LIMIT_ADMIN) { + adminMessageRoutes(messageRepository, userRepository, wsManager, adminMessageService, adminToken) + } + // WebSocket – auth via Authorization: Bearer header webSocketRoutes(wsManager, jwtService, messageRepository) diff --git a/server/src/main/kotlin/de/bollwerk/server/routes/AdminMessageRoutes.kt b/server/src/main/kotlin/de/bollwerk/server/routes/AdminMessageRoutes.kt new file mode 100644 index 0000000..3223629 --- /dev/null +++ b/server/src/main/kotlin/de/bollwerk/server/routes/AdminMessageRoutes.kt @@ -0,0 +1,116 @@ +package de.bollwerk.server.routes + +import de.bollwerk.server.model.ErrorResponse +import de.bollwerk.server.repository.MessageRepository +import de.bollwerk.server.repository.UserRepository +import de.bollwerk.server.service.AdminMessageService +import de.bollwerk.server.websocket.WebSocketManager +import de.bollwerk.shared.model.SendMessageResponse +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +internal data class AdminSendMessageRequest( + val id: String? = null, + val senderUsername: String, + val receiverId: String? = null, + val receiverUsername: String? = null, + val body: String, + val sentAt: Long +) + +internal fun Route.adminMessageRoutes( + messageRepository: MessageRepository, + userRepository: UserRepository, + wsManager: WebSocketManager, + adminMessageService: AdminMessageService, + adminToken: String +) { + route("/api/admin/send-message") { + post { + if (adminToken.isBlank()) { + return@post call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse(status = 500, message = "Server not configured for admin messages") + ) + } + + val authHeader = call.request.headers[HttpHeaders.Authorization] + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return@post call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse(status = 401, message = "Unauthorized") + ) + } + val token = authHeader.removePrefix("Bearer ").trim() + if (token != adminToken) { + return@post call.respond( + HttpStatusCode.Forbidden, + ErrorResponse(status = 403, message = "Forbidden") + ) + } + + val request = call.receive() + if (request.body.isBlank()) { + return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(status = 400, message = "Body must not be empty") + ) + } + + val sender = userRepository.findByUsername(request.senderUsername) + ?: return@post call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "Sender not found") + ) + + val receiver = when { + !request.receiverId.isNullOrBlank() -> userRepository.findById(request.receiverId) + !request.receiverUsername.isNullOrBlank() -> userRepository.findByUsername(request.receiverUsername) + else -> null + } + ?: return@post call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "Receiver not found") + ) + + val receiverId = receiver.id + + if (userRepository.findById(receiverId) == null) { + return@post call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "Receiver not found") + ) + } + + val msgId = request.id ?: UUID.randomUUID().toString() + + val encryptedBody = try { + adminMessageService.encryptMessage(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 = sender.id, + senderUsername = sender.username, + receiverId = receiverId, + body = encryptedBody, + sentAt = request.sentAt + ) + wsManager.notifyNewMessage(receiverId, message) + if (wsManager.isOnline(receiverId)) { + messageRepository.markDelivered(msgId) + } + call.respond(HttpStatusCode.Created, SendMessageResponse(message = message)) + } + } +} 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 518e4fa..7e6b519 100644 --- a/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt +++ b/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt @@ -4,7 +4,6 @@ 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.* @@ -28,8 +27,7 @@ internal data class SendMessageRequest( internal fun Route.messageRoutes( messageRepository: MessageRepository, userRepository: UserRepository, - wsManager: WebSocketManager, - adminMessageService: AdminMessageService + wsManager: WebSocketManager ) { route("/api/messages") { post { @@ -97,82 +95,4 @@ internal fun Route.messageRoutes( call.respond(HttpStatusCode.OK, messages) } } - - route("/api/admin/send-message") { - post { - val principal = call.principal() - ?: return@post call.respond( - HttpStatusCode.Unauthorized, - ErrorResponse(status = 401, message = "Unauthorized") - ) - // Only admins can send test messages - if (!principal.isAdmin) { - return@post call.respond( - HttpStatusCode.Forbidden, - ErrorResponse(status = 403, message = "Admin access required") - ) - } - - // 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( - HttpStatusCode.BadRequest, - ErrorResponse(status = 400, message = "Body must not be empty") - ) - return@post - } - if (userRepository.findById(request.receiverId) == null) { - call.respond( - HttpStatusCode.NotFound, - ErrorResponse(status = 404, message = "Receiver not found") - ) - return@post - } - - val msgId = request.id ?: UUID.randomUUID().toString() - - // 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 = encryptedBody, - sentAt = request.sentAt - ) - wsManager.notifyNewMessage(request.receiverId, message) - if (wsManager.isOnline(request.receiverId)) { - messageRepository.markDelivered(msgId) - } - call.respond(HttpStatusCode.Created, SendMessageResponse(message = message)) - } - } } diff --git a/server/src/test/kotlin/de/bollwerk/server/AdminMessageApiTest.kt b/server/src/test/kotlin/de/bollwerk/server/AdminMessageApiTest.kt new file mode 100644 index 0000000..35b043d --- /dev/null +++ b/server/src/test/kotlin/de/bollwerk/server/AdminMessageApiTest.kt @@ -0,0 +1,117 @@ +package de.bollwerk.server + +import de.bollwerk.server.db.DatabaseFactory +import de.bollwerk.server.model.ErrorResponse +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.config.* +import io.ktor.server.testing.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Test + +class AdminMessageApiTest { + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val adminJwt = createTestAccessToken( + userId = TEST_ADMIN_ID, + username = TEST_ADMIN_USERNAME, + isAdmin = true + ) + + private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + environment { + config = MapApplicationConfig(*testMapConfig().toTypedArray()) + } + application { + DatabaseFactory.init( + jdbcUrl = "jdbc:h2:mem:admin_msg_test_${System.nanoTime()};DB_CLOSE_DELAY=-1", + driver = "org.h2.Driver", + adminPassword = "test-admin-pw" + ) + configurePlugins() + } + block() + } + + private suspend fun ApplicationTestBuilder.createUser(username: String, password: String = "pass123"): String { + client.post("/api/admin/users") { + bearerAuth(adminJwt) + contentType(ContentType.Application.Json) + setBody("""{"username":"$username","password":"$password"}""") + } + val listResponse = client.get("/api/admin/users") { bearerAuth(adminJwt) } + val users = json.decodeFromString(listResponse.bodyAsText()) + return users.first { it.jsonObject["username"]?.jsonPrimitive?.content == username } + .jsonObject["id"]!!.jsonPrimitive.content + } + + @Test + fun test_adminSendMessage_noAuth_returns401() = testApp { + val response = client.post("/api/admin/send-message") { + contentType(ContentType.Application.Json) + setBody( + """{"senderUsername":"bob","receiverId":"any","body":"Hi","sentAt":1700000000000}""" + ) + } + + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun test_adminSendMessage_invalidToken_returns403() = testApp { + val response = client.post("/api/admin/send-message") { + header(HttpHeaders.Authorization, "Bearer wrong-token") + contentType(ContentType.Application.Json) + setBody( + """{"senderUsername":"bob","receiverId":"any","body":"Hi","sentAt":1700000000000}""" + ) + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + val error = json.decodeFromString(response.bodyAsText()) + assertEquals(403, error.status) + } + + @Test + fun test_adminSendMessage_senderNotFound_returns404() = testApp { + val receiverId = createUser("receiver") + + val response = client.post("/api/admin/send-message") { + header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN") + contentType(ContentType.Application.Json) + setBody( + """{"senderUsername":"missing-user","receiverId":"$receiverId","body":"Hi","sentAt":1700000000000}""" + ) + } + + assertEquals(HttpStatusCode.NotFound, response.status) + val error = json.decodeFromString(response.bodyAsText()) + assertEquals(404, error.status) + } + + @Test + fun test_adminSendMessage_receiverNotFound_returns404() = testApp { + createUser("bob") + + val response = client.post("/api/admin/send-message") { + header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN") + contentType(ContentType.Application.Json) + setBody( + """{"senderUsername":"bob","receiverId":"missing-id","body":"Hi","sentAt":1700000000000}""" + ) + } + + assertEquals(HttpStatusCode.NotFound, response.status) + val error = json.decodeFromString(response.bodyAsText()) + assertEquals(404, error.status) + } +}