diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 52fcdf5..3190dd5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,6 +45,11 @@ android { sourceSets { getByName("androidTest").assets.srcDirs("$projectDir/schemas") } + testOptions { + unitTests { + isReturnDefaultValues = true + } + } } ksp { @@ -75,6 +80,7 @@ dependencies { // Security implementation(libs.androidx.security.crypto) + implementation(libs.tink.android) // Navigation implementation(libs.androidx.navigation.compose) diff --git a/app/src/main/java/de/bollwerk/app/MainActivity.kt b/app/src/main/java/de/bollwerk/app/MainActivity.kt index ede6a80..db96830 100644 --- a/app/src/main/java/de/bollwerk/app/MainActivity.kt +++ b/app/src/main/java/de/bollwerk/app/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint +import de.bollwerk.app.domain.usecase.EnsureKeyPairUseCase import de.bollwerk.app.domain.usecase.SeedDatabaseUseCase import de.bollwerk.app.ui.MainScreen import de.bollwerk.app.ui.theme.BollwerkTheme @@ -20,10 +21,12 @@ import javax.inject.Inject class MainActivity : ComponentActivity() { @Inject internal lateinit var seedDatabaseUseCase: SeedDatabaseUseCase + @Inject internal lateinit var ensureKeyPairUseCase: EnsureKeyPairUseCase override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { seedDatabaseUseCase() } + lifecycleScope.launch { ensureKeyPairUseCase.execute() } enableEdgeToEdge() setContent { BollwerkTheme { diff --git a/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt b/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt index a5ecb90..bff53cb 100644 --- a/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt +++ b/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt @@ -1,7 +1,9 @@ package de.bollwerk.app.data.repository +import android.util.Log import de.bollwerk.app.data.db.dao.MessageDao import de.bollwerk.app.data.db.entity.MessageEntity +import de.bollwerk.app.data.security.E2EEKeyManager import de.bollwerk.app.data.sync.WebSocketClient import de.bollwerk.app.data.sync.WebSocketEvent import de.bollwerk.app.di.ApplicationScope @@ -28,6 +30,7 @@ import kotlinx.serialization.Serializable import java.net.ConnectException import java.net.SocketTimeoutException import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -39,33 +42,48 @@ private data class SendMessageRequest( val sentAt: Long ) +@Serializable +private data class PublicKeyResponse(val publicKey: String) + @Singleton internal class MessageRepositoryImpl @Inject constructor( private val dao: MessageDao, private val httpClient: HttpClient, private val settingsRepository: SettingsRepository, private val webSocketClient: WebSocketClient, + private val e2eeKeyManager: E2EEKeyManager, @ApplicationScope private val scope: CoroutineScope ) : MessageRepository { + private val publicKeyCache = ConcurrentHashMap() + init { scope.launch { webSocketClient.events.collect { event -> when (event) { is WebSocketEvent.NewMessage -> { val msg = event.message + val decryptedBody = try { + e2eeKeyManager.decryptMessage(msg.body) + } catch (e: Exception) { + Log.e(TAG, "E2EE: Failed to decrypt message ${msg.id}", e) + "[Entschlüsselung fehlgeschlagen]" + } dao.upsert( MessageEntity( id = msg.id, senderId = msg.senderId, senderUsername = msg.senderUsername, receiverId = msg.receiverId, - body = msg.body, + body = decryptedBody, sentAt = msg.sentAt, isPending = false ) ) } + is WebSocketEvent.KeyUpdated -> { + publicKeyCache[event.userId] = event.publicKey + } is WebSocketEvent.Connected -> drainPendingMessages() else -> Unit } @@ -82,6 +100,13 @@ internal class MessageRepositoryImpl @Inject constructor( val localId = UUID.randomUUID().toString() val sentAt = System.currentTimeMillis() + val recipientPublicKey = getRecipientPublicKey(recipientId) + if (recipientPublicKey == null) { + Log.e(TAG, "E2EE: No public key for recipient $recipientId – message not sent") + return + } + val encryptedBody = e2eeKeyManager.encryptMessage(recipientPublicKey, body) + dao.upsert( MessageEntity( id = localId, @@ -94,7 +119,7 @@ internal class MessageRepositoryImpl @Inject constructor( ) ) - val result = attemptSendToServer(localId, recipientId, body, sentAt) + val result = attemptSendToServer(localId, recipientId, encryptedBody, sentAt) if (result.isSuccess) { dao.markDelivered(localId) } @@ -130,13 +155,50 @@ internal class MessageRepositoryImpl @Inject constructor( override suspend fun drainPendingMessages() { val pending = withContext(Dispatchers.IO) { dao.getPendingMessages() } for (msg in pending) { - val result = attemptSendToServer(msg.id, msg.receiverId, msg.body, msg.sentAt) + val recipientPublicKey = getRecipientPublicKey(msg.receiverId) + if (recipientPublicKey == null) { + Log.w(TAG, "E2EE: No public key for recipient ${msg.receiverId} – skipping pending message ${msg.id}") + continue + } + val encryptedBody = try { + e2eeKeyManager.encryptMessage(recipientPublicKey, msg.body) + } catch (e: Exception) { + Log.e(TAG, "E2EE: Failed to encrypt pending message ${msg.id}", e) + continue + } + val result = attemptSendToServer(msg.id, msg.receiverId, encryptedBody, msg.sentAt) if (result.isSuccess) { withContext(Dispatchers.IO) { dao.markDelivered(msg.id) } } } } + private suspend fun getRecipientPublicKey(recipientId: String): String? { + publicKeyCache[recipientId]?.let { return it } + return withContext(Dispatchers.IO) { + val serverUrl = settingsRepository.getStringOrNull(StringKey.ServerUrl) + ?: return@withContext null + val token = settingsRepository.getStringOrNull(StringKey.AuthAccessToken) + ?: return@withContext null + try { + val response = httpClient.get("${serverUrl.trimEnd('/')}/api/users/$recipientId/public-key") { + header("Authorization", "Bearer $token") + } + if (response.status == HttpStatusCode.OK) { + val key = response.body().publicKey + publicKeyCache[recipientId] = key + key + } else { + Log.w(TAG, "E2EE: No public key found for $recipientId (${response.status})") + null + } + } catch (e: Exception) { + Log.e(TAG, "E2EE: Failed to fetch public key for $recipientId", e) + null + } + } + } + private suspend fun attemptSendToServer( id: String, recipientId: String, @@ -166,4 +228,8 @@ internal class MessageRepositoryImpl @Inject constructor( Result.failure(SyncError.Unknown(e)) } } + + private companion object { + const val TAG = "MessageRepository" + } } diff --git a/app/src/main/java/de/bollwerk/app/data/security/E2EEKeyManager.kt b/app/src/main/java/de/bollwerk/app/data/security/E2EEKeyManager.kt new file mode 100644 index 0000000..12b8464 --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/data/security/E2EEKeyManager.kt @@ -0,0 +1,76 @@ +package de.bollwerk.app.data.security + +import com.google.crypto.tink.CleartextKeysetHandle +import com.google.crypto.tink.HybridDecrypt +import com.google.crypto.tink.HybridEncrypt +import com.google.crypto.tink.JsonKeysetReader +import com.google.crypto.tink.JsonKeysetWriter +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.KeysetHandle +import com.google.crypto.tink.hybrid.HybridConfig +import de.bollwerk.app.domain.model.SettingsKey.StringKey +import java.io.ByteArrayOutputStream +import java.util.Base64 +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class E2EEKeyManager @Inject constructor( + private val secureStorage: SecureTokenStorage +) { + + init { + HybridConfig.register() + } + + fun hasKeyPair(): Boolean = + secureStorage.get(StringKey.E2EEPrivateKeyset.key) != null + + fun generateAndStoreKeyPair() { + val template = KeyTemplates.get("DHKEM_X25519_HKDF_SHA256_HKDF_SHA256_CHACHA20_POLY1305") + val privateHandle = KeysetHandle.generateNew(template) + + val privateKeysetJson = serializeKeysetToJson(privateHandle) + secureStorage.set(StringKey.E2EEPrivateKeyset.key, privateKeysetJson) + + val publicHandle = privateHandle.publicKeysetHandle + val publicKeysetJson = serializeKeysetToJson(publicHandle) + val publicKeyBase64 = Base64.getEncoder().encodeToString( + publicKeysetJson.toByteArray(Charsets.UTF_8) + ) + secureStorage.set(StringKey.E2EEPublicKeyBase64.key, publicKeyBase64) + } + + fun getOwnPublicKeyBase64(): String? = + secureStorage.get(StringKey.E2EEPublicKeyBase64.key) + + fun encryptMessage(recipientPublicKeyBase64: String, plaintext: String): String { + val publicKeysetJson = String( + Base64.getDecoder().decode(recipientPublicKeyBase64), + Charsets.UTF_8 + ) + val publicHandle = deserializeKeysetFromJson(publicKeysetJson) + val hybridEncrypt = publicHandle.getPrimitive(HybridEncrypt::class.java) + val ciphertext = hybridEncrypt.encrypt(plaintext.toByteArray(Charsets.UTF_8), null) + return Base64.getEncoder().encodeToString(ciphertext) + } + + fun decryptMessage(ciphertextBase64: String): String { + val privateKeysetJson = secureStorage.get(StringKey.E2EEPrivateKeyset.key) + ?: throw IllegalStateException("E2EE: No private key available for decryption") + val privateHandle = deserializeKeysetFromJson(privateKeysetJson) + val hybridDecrypt = privateHandle.getPrimitive(HybridDecrypt::class.java) + val ciphertext = Base64.getDecoder().decode(ciphertextBase64) + val plaintextBytes = hybridDecrypt.decrypt(ciphertext, null) + return String(plaintextBytes, Charsets.UTF_8) + } + + private fun serializeKeysetToJson(handle: KeysetHandle): String { + val outputStream = ByteArrayOutputStream() + CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(outputStream)) + return outputStream.toString(Charsets.UTF_8.name()) + } + + private fun deserializeKeysetFromJson(json: String): KeysetHandle = + CleartextKeysetHandle.read(JsonKeysetReader.withString(json)) +} diff --git a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClient.kt b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClient.kt index 7293a97..584aeec 100644 --- a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClient.kt +++ b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClient.kt @@ -18,4 +18,5 @@ internal sealed interface WebSocketEvent { data object Disconnected : WebSocketEvent data class ConnectionFailed(val message: String) : WebSocketEvent data class NewMessage(val message: MessageDto) : WebSocketEvent + data class KeyUpdated(val userId: String, val publicKey: String) : WebSocketEvent } diff --git a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt index c1f023c..de7ffdf 100644 --- a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt +++ b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt @@ -1,5 +1,6 @@ package de.bollwerk.app.data.sync +import android.util.Log import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.websocket.WebSockets @@ -131,13 +132,19 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { ) _events.emit(WebSocketEvent.NewMessage(msg)) } + "key_updated" -> { + val userId = event.userId ?: return + val publicKey = event.publicKey ?: return + _events.emit(WebSocketEvent.KeyUpdated(userId, publicKey)) + } } - } catch (_: Exception) { - // ignore malformed events + } catch (e: Exception) { + Log.w(TAG, "WebSocket: Failed to handle frame: $text", e) } } private companion object { + const val TAG = "WebSocketClient" const val INITIAL_BACKOFF_MS = 2_000L const val MAX_BACKOFF_MS = 60_000L const val MAX_RETRIES = 5 @@ -155,5 +162,7 @@ private data class WsServerEvent( val senderUsername: String? = null, val receiverId: String? = null, val body: String? = null, - val sentAt: Long? = null + val sentAt: Long? = null, + val userId: String? = null, + val publicKey: String? = null ) diff --git a/app/src/main/java/de/bollwerk/app/domain/model/SettingsKey.kt b/app/src/main/java/de/bollwerk/app/domain/model/SettingsKey.kt index ca1b0bc..ca4a1fe 100644 --- a/app/src/main/java/de/bollwerk/app/domain/model/SettingsKey.kt +++ b/app/src/main/java/de/bollwerk/app/domain/model/SettingsKey.kt @@ -20,6 +20,8 @@ internal sealed class SettingsKey(val key: String, val defaultValue: T) { data object OpenAiApiKey : StringKey("openai_api_key") data object ActiveInventoryId : StringKey("active_inventory_id") data object ActiveInventoryName : StringKey("active_inventory_name") + data object E2EEPrivateKeyset : StringKey("e2ee_private_keyset") + data object E2EEPublicKeyBase64 : StringKey("e2ee_public_key") } companion object { @@ -30,7 +32,9 @@ internal sealed class SettingsKey(val key: String, val defaultValue: T) { StringKey.AuthRefreshToken, StringKey.AuthUsername, StringKey.AuthUserId, - StringKey.OpenAiApiKey + StringKey.OpenAiApiKey, + StringKey.E2EEPrivateKeyset, + StringKey.E2EEPublicKeyBase64 ) val SENSITIVE_KEY_STRINGS: Set = SENSITIVE_KEYS.map { it.key }.toSet() diff --git a/app/src/main/java/de/bollwerk/app/domain/usecase/EnsureKeyPairUseCase.kt b/app/src/main/java/de/bollwerk/app/domain/usecase/EnsureKeyPairUseCase.kt new file mode 100644 index 0000000..cdd609f --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/domain/usecase/EnsureKeyPairUseCase.kt @@ -0,0 +1,66 @@ +package de.bollwerk.app.domain.usecase + +import android.util.Log +import de.bollwerk.app.data.security.E2EEKeyManager +import de.bollwerk.app.domain.model.SettingsKey.StringKey +import de.bollwerk.app.domain.repository.SettingsRepository +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import javax.inject.Inject +import javax.inject.Singleton + +@Serializable +private data class SetPublicKeyRequest(val publicKey: String) + +@Singleton +internal class EnsureKeyPairUseCase @Inject constructor( + private val e2eeKeyManager: E2EEKeyManager, + private val settingsRepository: SettingsRepository, + private val httpClient: HttpClient +) { + + suspend fun execute() { + try { + if (!e2eeKeyManager.hasKeyPair()) { + e2eeKeyManager.generateAndStoreKeyPair() + Log.d(TAG, "E2EE: Generated new keypair") + } + uploadPublicKeyToServer() + } catch (e: Exception) { + Log.e(TAG, "E2EE: EnsureKeyPairUseCase failed", e) + } + } + + private suspend fun uploadPublicKeyToServer() = withContext(Dispatchers.IO) { + val userId = settingsRepository.getStringOrNull(StringKey.AuthUserId) + ?: return@withContext + val serverUrl = settingsRepository.getStringOrNull(StringKey.ServerUrl) + ?: return@withContext + val token = settingsRepository.getStringOrNull(StringKey.AuthAccessToken) + ?: return@withContext + val publicKey = e2eeKeyManager.getOwnPublicKeyBase64() + ?: return@withContext + + try { + httpClient.put("${serverUrl.trimEnd('/')}/api/users/$userId/public-key") { + header("Authorization", "Bearer $token") + contentType(ContentType.Application.Json) + setBody(SetPublicKeyRequest(publicKey = publicKey)) + } + Log.d(TAG, "E2EE: Public key uploaded for user $userId") + } catch (e: Exception) { + Log.w(TAG, "E2EE: Failed to upload public key", e) + } + } + + private companion object { + const val TAG = "EnsureKeyPairUseCase" + } +} diff --git a/app/src/test/java/de/bollwerk/app/data/repository/MessageRepositoryImplTest.kt b/app/src/test/java/de/bollwerk/app/data/repository/MessageRepositoryImplTest.kt index 083516c..38f71f3 100644 --- a/app/src/test/java/de/bollwerk/app/data/repository/MessageRepositoryImplTest.kt +++ b/app/src/test/java/de/bollwerk/app/data/repository/MessageRepositoryImplTest.kt @@ -1,7 +1,8 @@ -package de.bollwerk.app.data.repository +package de.bollwerk.app.data.repository import de.bollwerk.app.data.db.dao.MessageDao import de.bollwerk.app.data.db.entity.MessageEntity +import de.bollwerk.app.data.security.E2EEKeyManager import de.bollwerk.app.data.sync.WebSocketClient import de.bollwerk.app.data.sync.WebSocketEvent import de.bollwerk.app.domain.model.SettingsKey @@ -19,6 +20,8 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json +import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.cancel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -33,6 +36,7 @@ import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -79,12 +83,20 @@ private class FakeMessageSettingsRepository : SettingsRepository { private class FakeMessageWsClient : WebSocketClient { val events2 = MutableSharedFlow(extraBufferCapacity = 16) override val events: SharedFlow = events2.asSharedFlow() - private val _connectionState = MutableStateFlow(de.bollwerk.app.data.sync.ConnectionState.NotConfigured) - override val connectionState: kotlinx.coroutines.flow.StateFlow = _connectionState + private val _connectionState = MutableStateFlow( + de.bollwerk.app.data.sync.ConnectionState.NotConfigured + ) + override val connectionState: kotlinx.coroutines.flow.StateFlow = + _connectionState override fun connect(serverUrl: String, accessToken: String) = Unit override fun disconnect() = Unit } +private fun buildFakeE2EEKeyManager(): E2EEKeyManager = mockk(relaxed = true).also { + every { it.encryptMessage(any(), any()) } answers { secondArg() } + every { it.decryptMessage(any()) } answers { firstArg() } +} + @OptIn(ExperimentalCoroutinesApi::class) class MessageRepositoryImplTest { @@ -104,12 +116,14 @@ class MessageRepositoryImplTest { dao: FakeMessageDao, httpClient: HttpClient, settings: FakeMessageSettingsRepository, - wsClient: FakeMessageWsClient = FakeMessageWsClient() + wsClient: FakeMessageWsClient = FakeMessageWsClient(), + e2eeKeyManager: E2EEKeyManager = buildFakeE2EEKeyManager() ) = MessageRepositoryImpl( dao = dao, httpClient = httpClient, settingsRepository = settings, webSocketClient = wsClient, + e2eeKeyManager = e2eeKeyManager, scope = testScope ) @@ -123,12 +137,20 @@ class MessageRepositoryImplTest { set(SettingsKeys.AUTH_USER_ID, "user1") set(SettingsKeys.AUTH_USERNAME, "Alice") } - val engine = MockEngine { - respond( - content = """{"id":"x","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hi","sentAt":1}""", - status = HttpStatusCode.Created, - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - ) + val engine = MockEngine { request -> + if (request.url.encodedPath.contains("public-key")) { + respond( + content = """{"publicKey":"dGVzdGtleQ=="}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } else { + respond( + content = """{"id":"x","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hi","sentAt":1}""", + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } } val repo = buildRepository(dao, createClient(engine), settings) @@ -149,7 +171,17 @@ class MessageRepositoryImplTest { set(SettingsKeys.AUTH_USER_ID, "user1") set(SettingsKeys.AUTH_USERNAME, "Alice") } - val engine = MockEngine { respondError(HttpStatusCode.ServiceUnavailable) } + val engine = MockEngine { request -> + if (request.url.encodedPath.contains("public-key")) { + respond( + content = """{"publicKey":"dGVzdGtleQ=="}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } else { + respondError(HttpStatusCode.ServiceUnavailable) + } + } val repo = buildRepository(dao, createClient(engine), settings) // When @@ -171,12 +203,20 @@ class MessageRepositoryImplTest { set(SettingsKeys.SERVER_URL, "http://localhost:8080") set(SettingsKeys.AUTH_ACCESS_TOKEN, "token") } - val engine = MockEngine { - respond( - content = """{"id":"m1","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hello","sentAt":1000}""", - status = HttpStatusCode.Created, - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - ) + val engine = MockEngine { request -> + if (request.url.encodedPath.contains("public-key")) { + respond( + content = """{"publicKey":"dGVzdGtleQ=="}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } else { + respond( + content = """{"id":"m1","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hello","sentAt":1000}""", + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } } val repo = buildRepository(dao, createClient(engine), settings) @@ -223,7 +263,6 @@ class MessageRepositoryImplTest { settings = FakeMessageSettingsRepository(), wsClient = wsClient ) - // Let init block start and subscribe before emitting testScope.advanceUntilIdle() val msg = MessageDto("m1", "u2", "Bob", "u1", "hey", 1000L) @@ -232,8 +271,177 @@ class MessageRepositoryImplTest { wsClient.events2.emit(WebSocketEvent.NewMessage(msg)) testScope.advanceUntilIdle() - // Then + // Then – decryptMessage is mocked to return plaintext as-is assertTrue(dao.upserted.any { it.id == "m1" && !it.isPending }) } + + @Test + fun test_sendMessage_recipientHasPublicKey_encryptsBody() = runTest { + // Given + val dao = FakeMessageDao() + val settings = FakeMessageSettingsRepository().apply { + set(SettingsKeys.SERVER_URL, "http://localhost:8080") + set(SettingsKeys.AUTH_ACCESS_TOKEN, "token") + set(SettingsKeys.AUTH_USER_ID, "user1") + set(SettingsKeys.AUTH_USERNAME, "Alice") + } + val engine = MockEngine { request -> + if (request.url.encodedPath.contains("public-key")) { + respond( + content = """{"publicKey":"dGVzdGtleQ=="}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } else { + respond( + content = "", + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + } + val e2eeKeyManager = mockk(relaxed = true).also { + every { it.encryptMessage(any(), any()) } returns "ENCRYPTED_BODY" + every { it.decryptMessage(any()) } answers { firstArg() } + } + val repo = buildRepository(dao, createClient(engine), settings, e2eeKeyManager = e2eeKeyManager) + + // When + repo.sendMessage("user2", "hello plaintext") + + // Then – encryptMessage was called with the original plaintext + io.mockk.verify(exactly = 1) { e2eeKeyManager.encryptMessage(any(), "hello plaintext") } + // And the message was delivered (server accepted the encrypted body) + assertTrue(dao.delivered.isNotEmpty()) + } + + @Test + fun test_sendMessage_noPublicKey_doesNotSend() = runTest { + // Given + val dao = FakeMessageDao() + val settings = FakeMessageSettingsRepository().apply { + set(SettingsKeys.SERVER_URL, "http://localhost:8080") + set(SettingsKeys.AUTH_ACCESS_TOKEN, "token") + set(SettingsKeys.AUTH_USER_ID, "user1") + set(SettingsKeys.AUTH_USERNAME, "Alice") + } + val sentRequests = mutableListOf() + val engine = MockEngine { request -> + if (request.url.encodedPath.contains("public-key")) { + respondError(HttpStatusCode.NotFound) + } else { + sentRequests += request.url.encodedPath + respondError(HttpStatusCode.ServiceUnavailable) + } + } + val repo = buildRepository(dao, createClient(engine), settings) + + // When + repo.sendMessage("user2", "hello") + + // Then – message POST should not be called when public key unavailable + assertTrue("No message should be sent without public key", sentRequests.isEmpty()) + // Message should also not be stored as pending (nothing to retry) + assertTrue(dao.upserted.isEmpty()) + } + + @Test + fun test_receiveMessage_decryptsBody() = runTest { + // Given + val dao = FakeMessageDao() + val wsClient = FakeMessageWsClient() + val e2eeKeyManager = mockk(relaxed = true).also { + every { it.decryptMessage(any()) } returns "decrypted plaintext" + } + buildRepository( + dao = dao, + httpClient = createClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }), + settings = FakeMessageSettingsRepository(), + wsClient = wsClient, + e2eeKeyManager = e2eeKeyManager + ) + testScope.advanceUntilIdle() + + val msg = MessageDto("m99", "u2", "Bob", "u1", "BASE64CIPHERTEXT", 2000L) + + // When + wsClient.events2.emit(WebSocketEvent.NewMessage(msg)) + testScope.advanceUntilIdle() + + // Then – stored body should be the decrypted text + val stored = dao.upserted.first { it.id == "m99" } + assertEquals("decrypted plaintext", stored.body) + assertFalse(stored.isPending) + } + + @Test + fun test_receiveMessage_decryptFails_storesFallbackText() = runTest { + // Given + val dao = FakeMessageDao() + val wsClient = FakeMessageWsClient() + val e2eeKeyManager = mockk(relaxed = true).also { + every { it.decryptMessage(any()) } throws IllegalStateException("No private key") + } + buildRepository( + dao = dao, + httpClient = createClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }), + settings = FakeMessageSettingsRepository(), + wsClient = wsClient, + e2eeKeyManager = e2eeKeyManager + ) + testScope.advanceUntilIdle() + + val msg = MessageDto("m77", "u2", "Bob", "u1", "BADCIPHERTEXT", 3000L) + + // When + wsClient.events2.emit(WebSocketEvent.NewMessage(msg)) + testScope.advanceUntilIdle() + + // Then – fallback text stored in DB + val stored = dao.upserted.first { it.id == "m77" } + assertEquals("[Entschlüsselung fehlgeschlagen]", stored.body) + } + + @Test + fun test_keyUpdated_updatesPublicKeyCache() = runTest { + // Given + val dao = FakeMessageDao() + val wsClient = FakeMessageWsClient() + val settings = FakeMessageSettingsRepository().apply { + set(SettingsKeys.SERVER_URL, "http://localhost:8080") + set(SettingsKeys.AUTH_ACCESS_TOKEN, "token") + set(SettingsKeys.AUTH_USER_ID, "user1") + set(SettingsKeys.AUTH_USERNAME, "Alice") + } + val publicKeyFetchRequests = mutableListOf() + val engine = MockEngine { request -> + if (request.url.encodedPath.contains("public-key")) { + publicKeyFetchRequests += request.url.encodedPath + respondError(HttpStatusCode.ServiceUnavailable) + } else { + respond(content = "", status = HttpStatusCode.Created) + } + } + val e2eeKeyManager = mockk(relaxed = true).also { + every { it.encryptMessage(any(), any()) } returns "ENCRYPTED" + every { it.decryptMessage(any()) } answers { firstArg() } + } + val repo = buildRepository(dao, createClient(engine), settings, wsClient = wsClient, e2eeKeyManager = e2eeKeyManager) + testScope.advanceUntilIdle() + + // Simulate KeyUpdated event – should populate cache for user2 + wsClient.events2.emit(WebSocketEvent.KeyUpdated("user2", "cachedPublicKey==")) + testScope.advanceUntilIdle() + + // When – send a message to user2 (whose key is now cached) + repo.sendMessage("user2", "hello") + + // Then – no HTTP request to fetch public key (was served from cache) + assertTrue( + "Public key should be served from cache, not fetched from server", + publicKeyFetchRequests.none { it.contains("user2") } + ) + } } + diff --git a/app/src/test/java/de/bollwerk/app/data/security/E2EEKeyManagerTest.kt b/app/src/test/java/de/bollwerk/app/data/security/E2EEKeyManagerTest.kt new file mode 100644 index 0000000..76587fd --- /dev/null +++ b/app/src/test/java/de/bollwerk/app/data/security/E2EEKeyManagerTest.kt @@ -0,0 +1,103 @@ +package de.bollwerk.app.data.security + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class E2EEKeyManagerTest { + + private lateinit var storage: FakeSecureTokenStorage + private lateinit var keyManager: E2EEKeyManager + + @Before + fun setUp() { + storage = FakeSecureTokenStorage() + keyManager = E2EEKeyManager(storage) + } + + @Test + fun test_hasKeyPair_noKeyStored_returnsFalse() { + // Given: empty storage + + // When + val result = keyManager.hasKeyPair() + + // Then + assertFalse(result) + } + + @Test + fun test_hasKeyPair_afterGenerate_returnsTrue() { + // Given + keyManager.generateAndStoreKeyPair() + + // When + val result = keyManager.hasKeyPair() + + // Then + assertTrue(result) + } + + @Test + fun test_encryptDecrypt_roundtrip_succeeds() { + // Given + keyManager.generateAndStoreKeyPair() + val plaintext = "Hello, E2EE World! 🔒" + val recipientPublicKey = keyManager.getOwnPublicKeyBase64() + assertNotNull(recipientPublicKey) + + // When + val ciphertext = keyManager.encryptMessage(recipientPublicKey!!, plaintext) + val decrypted = keyManager.decryptMessage(ciphertext) + + // Then + assertEquals(plaintext, decrypted) + } + + @Test + fun test_encryptMessage_differentNonce_producesUniqueOutput() { + // Given + keyManager.generateAndStoreKeyPair() + val plaintext = "Same message" + val recipientPublicKey = keyManager.getOwnPublicKeyBase64()!! + + // When + val ciphertext1 = keyManager.encryptMessage(recipientPublicKey, plaintext) + val ciphertext2 = keyManager.encryptMessage(recipientPublicKey, plaintext) + + // Then – HPKE uses ephemeral randomness per encryption + assertNotEquals(ciphertext1, ciphertext2) + } + + @Test(expected = Exception::class) + fun test_decryptMessage_wrongKey_throwsException() { + // Given: two separate key managers with different keys + val storage2 = FakeSecureTokenStorage() + val otherKeyManager = E2EEKeyManager(storage2) + + keyManager.generateAndStoreKeyPair() + otherKeyManager.generateAndStoreKeyPair() + + val plaintext = "Secret" + val encryptedForOther = otherKeyManager.encryptMessage( + otherKeyManager.getOwnPublicKeyBase64()!!, + plaintext + ) + + // When: try to decrypt with a different (wrong) key + // Then: should throw an Exception + keyManager.decryptMessage(encryptedForOther) + } +} + +private class FakeSecureTokenStorage : SecureTokenStorage { + private val store = HashMap() + + override fun get(key: String): String? = store[key] + override fun set(key: String, value: String) { store[key] = value } + override fun remove(key: String) { store.remove(key) } +} diff --git a/app/src/test/java/de/bollwerk/app/domain/usecase/EnsureKeyPairUseCaseTest.kt b/app/src/test/java/de/bollwerk/app/domain/usecase/EnsureKeyPairUseCaseTest.kt new file mode 100644 index 0000000..8926c8b --- /dev/null +++ b/app/src/test/java/de/bollwerk/app/domain/usecase/EnsureKeyPairUseCaseTest.kt @@ -0,0 +1,170 @@ +package de.bollwerk.app.domain.usecase + +import de.bollwerk.app.data.db.entity.SettingsEntity +import de.bollwerk.app.data.security.E2EEKeyManager +import de.bollwerk.app.domain.model.SettingsKey +import de.bollwerk.app.domain.repository.SettingsRepository +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +private class FakeEnsureKeyPairSettingsRepository : SettingsRepository { + private val store = mutableMapOf() + + fun set(key: String, value: String) { store[key] = value } + + override suspend fun getValue(key: String): String? = store[key] + override suspend fun setValue(key: String, value: String) { store[key] = value } + override fun observeValue(key: String): Flow = MutableStateFlow(store[key]) + override fun getAll(): Flow> = MutableStateFlow(emptyList()) + override suspend fun getString(key: SettingsKey.StringKey): String = + store[key.key] ?: key.defaultValue + override suspend fun getStringOrNull(key: SettingsKey.StringKey): String? = store[key.key] + override suspend fun setString(key: SettingsKey.StringKey, value: String) { store[key.key] = value } + override fun observeString(key: SettingsKey.StringKey): Flow = + MutableStateFlow(store[key.key] ?: key.defaultValue) +} + +@OptIn(ExperimentalCoroutinesApi::class) +class EnsureKeyPairUseCaseTest { + + private val jsonSerializer = Json { ignoreUnknownKeys = true } + + private fun buildUseCase( + e2eeKeyManager: E2EEKeyManager, + settings: FakeEnsureKeyPairSettingsRepository, + engine: MockEngine + ): EnsureKeyPairUseCase { + val httpClient = HttpClient(engine) { + install(ContentNegotiation) { json(jsonSerializer) } + } + return EnsureKeyPairUseCase( + e2eeKeyManager = e2eeKeyManager, + settingsRepository = settings, + httpClient = httpClient + ) + } + + @Test + fun test_execute_noKeyPair_generatesAndUploads() = runTest { + // Given + val putRequests = mutableListOf() + val e2eeKeyManager = mockk().also { + every { it.hasKeyPair() } returns false + every { it.generateAndStoreKeyPair() } just runs + every { it.getOwnPublicKeyBase64() } returns "dGVzdHB1YmxpY2tleQ==" + } + val settings = FakeEnsureKeyPairSettingsRepository().apply { + set(SettingsKey.StringKey.AuthUserId.key, "user1") + set(SettingsKey.StringKey.ServerUrl.key, "http://localhost:8080") + set(SettingsKey.StringKey.AuthAccessToken.key, "token123") + } + val engine = MockEngine { request -> + putRequests += request.url.encodedPath + respond( + content = "", + status = HttpStatusCode.NoContent, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + val useCase = buildUseCase(e2eeKeyManager, settings, engine) + + // When + useCase.execute() + + // Then + coVerify(exactly = 1) { e2eeKeyManager.generateAndStoreKeyPair() } + assertEquals(1, putRequests.size) + assertEquals("/api/users/user1/public-key", putRequests.first()) + } + + @Test + fun test_execute_keyPairExists_doesNotRegenerate() = runTest { + // Given + val e2eeKeyManager = mockk().also { + every { it.hasKeyPair() } returns true + every { it.getOwnPublicKeyBase64() } returns "dGVzdHB1YmxpY2tleQ==" + } + val settings = FakeEnsureKeyPairSettingsRepository().apply { + set(SettingsKey.StringKey.AuthUserId.key, "user1") + set(SettingsKey.StringKey.ServerUrl.key, "http://localhost:8080") + set(SettingsKey.StringKey.AuthAccessToken.key, "token123") + } + val engine = MockEngine { + respond(content = "", status = HttpStatusCode.NoContent) + } + val useCase = buildUseCase(e2eeKeyManager, settings, engine) + + // When + useCase.execute() + + // Then + coVerify(exactly = 0) { e2eeKeyManager.generateAndStoreKeyPair() } + } + + @Test + fun test_execute_noUserId_doesNotCallServer() = runTest { + // Given + val requestCount = mutableListOf() + val e2eeKeyManager = mockk().also { + every { it.hasKeyPair() } returns true + every { it.getOwnPublicKeyBase64() } returns "dGVzdHB1YmxpY2tleQ==" + } + val settings = FakeEnsureKeyPairSettingsRepository().apply { + // AuthUserId NOT set + set(SettingsKey.StringKey.ServerUrl.key, "http://localhost:8080") + set(SettingsKey.StringKey.AuthAccessToken.key, "token123") + } + val engine = MockEngine { request -> + requestCount += request.url.encodedPath + respond(content = "", status = HttpStatusCode.NoContent) + } + val useCase = buildUseCase(e2eeKeyManager, settings, engine) + + // When + useCase.execute() + + // Then + assertEquals(0, requestCount.size) + } + + @Test + fun test_execute_serverError_doesNotCrash() = runTest { + // Given + val e2eeKeyManager = mockk().also { + every { it.hasKeyPair() } returns true + every { it.getOwnPublicKeyBase64() } returns "dGVzdHB1YmxpY2tleQ==" + } + val settings = FakeEnsureKeyPairSettingsRepository().apply { + set(SettingsKey.StringKey.AuthUserId.key, "user1") + set(SettingsKey.StringKey.ServerUrl.key, "http://localhost:8080") + set(SettingsKey.StringKey.AuthAccessToken.key, "token123") + } + val engine = MockEngine { + respondError(HttpStatusCode.InternalServerError) + } + val useCase = buildUseCase(e2eeKeyManager, settings, engine) + + // When / Then – must not throw + useCase.execute() + } +} diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 04e08f3..04cb936 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -65,3 +65,13 @@ ksp { arg("room.schemaLocation", "$projectDir/schemas") } ``` + +## E2EE – Bekannte Einschränkungen + +### Pending Messages im lokalen SQLite-Speicher + +Nachrichten, die noch nicht erfolgreich an den Server gesendet wurden (Offline-Zustand), werden temporär als **Klartext** in der lokalen Room-Datenbank (`messages`-Tabelle, `is_pending = true`) gespeichert. Dies entspricht dem Standardverhalten von Offline-First E2EE-Messaging-Apps (z. B. Signal). + +**Sicherheitsbeurteilung:** Die Room-Datenbank liegt im App-privaten Verzeichnis (`/data/data/de.bollwerk.app/databases/`). Auf normalen (nicht gerooteten) Geräten ist sie für andere Apps nicht zugänglich. Ein Angreifer mit physischem Zugriff auf ein gerootetes Gerät oder ADB-Zugriff (Developer Mode) könnte die Pending-Message-Klartexte theoretisch auslesen. + +**Geplante Maßnahme:** Verschlüsselung der lokalen Message-DB mit SQLCipher (zukünftiges Feature). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8cb64a..a8d969b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.7.3" kotlin = "2.1.10" +tink = "1.15.0" ksp = "2.1.10-1.0.29" coreKtx = "1.15.0" junit = "4.13.2" @@ -78,6 +79,7 @@ logback-classic = { group = "ch.qos.logback", name = "logback-classic", version. exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" } exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" } h2-database = { group = "com.h2database", name = "h2", version.ref = "h2" } +tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink" } postgresql = { group = "org.postgresql", name = "postgresql", version.ref = "postgresql" } hikaricp = { group = "com.zaxxer", name = "HikariCP", version.ref = "hikaricp" } diff --git a/server/src/main/kotlin/de/bollwerk/server/db/Tables.kt b/server/src/main/kotlin/de/bollwerk/server/db/Tables.kt index d123b5a..cc87137 100644 --- a/server/src/main/kotlin/de/bollwerk/server/db/Tables.kt +++ b/server/src/main/kotlin/de/bollwerk/server/db/Tables.kt @@ -16,6 +16,7 @@ internal object Users : Table("users") { val createdAt = long("created_at") val isAdmin = bool("is_admin").default(false) val inventoryId = varchar("inventory_id", 36).nullable() + val publicKey = text("public_key").nullable() override val primaryKey = PrimaryKey(id) } 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 9f069e5..187c00f 100644 --- a/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt +++ b/server/src/main/kotlin/de/bollwerk/server/plugins/Routing.kt @@ -85,7 +85,7 @@ internal fun Application.configureRouting( rateLimit(RATE_LIMIT_MESSAGES) { messageRoutes(messageRepository, userRepository, wsManager) } - userRoutes(userRepository) + userRoutes(userRepository, wsManager) } // WebSocket – auth via query param ?token= diff --git a/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt b/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt index 8edf24c..ea04bbf 100644 --- a/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt +++ b/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt @@ -1,6 +1,5 @@ package de.bollwerk.server.repository -import de.bollwerk.server.db.EncryptionService import de.bollwerk.server.db.Messages import de.bollwerk.server.db.Users import de.bollwerk.shared.model.MessageDto @@ -29,7 +28,8 @@ internal class MessageRepository { it[Messages.id] = id it[Messages.senderId] = senderId it[Messages.receiverId] = receiverId - it[Messages.body] = EncryptionService.encrypt(body) + // E2EE: body is client-side encrypted; server stores ciphertext as-is + it[Messages.body] = body it[Messages.sentAt] = sentAt it[Messages.deliveredAt] = null } @@ -55,7 +55,8 @@ internal class MessageRepository { senderId = row[Messages.senderId], senderUsername = row.getOrNull(Users.username) ?: "", receiverId = row[Messages.receiverId], - body = EncryptionService.decrypt(row[Messages.body]), + // E2EE: body is client-side encrypted; server returns ciphertext as-is + body = row[Messages.body], sentAt = row[Messages.sentAt], deliveredAt = row[Messages.deliveredAt] ) @@ -94,7 +95,8 @@ internal class MessageRepository { senderId = row[Messages.senderId], senderUsername = row.getOrNull(Users.username) ?: "", receiverId = row[Messages.receiverId], - body = EncryptionService.decrypt(row[Messages.body]), + // E2EE: body is client-side encrypted; server returns ciphertext as-is + body = row[Messages.body], sentAt = row[Messages.sentAt], deliveredAt = row[Messages.deliveredAt] ) diff --git a/server/src/main/kotlin/de/bollwerk/server/repository/UserRepository.kt b/server/src/main/kotlin/de/bollwerk/server/repository/UserRepository.kt index 1c893e0..f8f4904 100644 --- a/server/src/main/kotlin/de/bollwerk/server/repository/UserRepository.kt +++ b/server/src/main/kotlin/de/bollwerk/server/repository/UserRepository.kt @@ -79,6 +79,19 @@ internal class UserRepository { fun count(): Long = transaction { Users.selectAll().count() } + fun getPublicKey(userId: String): String? = transaction { + Users.selectAll() + .where { Users.id eq userId } + .map { it[Users.publicKey] } + .singleOrNull() + } + + fun setPublicKey(userId: String, publicKey: String): Boolean = transaction { + Users.update({ Users.id eq userId }) { + it[Users.publicKey] = publicKey + } > 0 + } + private fun org.jetbrains.exposed.sql.ResultRow.toUserRow() = UserRow( id = this[Users.id], username = this[Users.username], diff --git a/server/src/main/kotlin/de/bollwerk/server/routes/UserRoutes.kt b/server/src/main/kotlin/de/bollwerk/server/routes/UserRoutes.kt index c0de6cc..6c63ae1 100644 --- a/server/src/main/kotlin/de/bollwerk/server/routes/UserRoutes.kt +++ b/server/src/main/kotlin/de/bollwerk/server/routes/UserRoutes.kt @@ -3,13 +3,22 @@ package de.bollwerk.server.routes import de.bollwerk.server.model.ErrorResponse import de.bollwerk.server.repository.UserRepository import de.bollwerk.server.security.UserPrincipal +import de.bollwerk.server.websocket.WebSocketManager import de.bollwerk.shared.model.UserListItemDto import io.ktor.http.* import io.ktor.server.auth.* +import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import kotlinx.serialization.Serializable -internal fun Route.userRoutes(userRepository: UserRepository) { +@Serializable +internal data class SetPublicKeyRequest(val publicKey: String) + +@Serializable +internal data class PublicKeyResponse(val publicKey: String) + +internal fun Route.userRoutes(userRepository: UserRepository, webSocketManager: WebSocketManager) { get("/api/users") { val principal = call.principal() ?: return@get call.respond( @@ -21,4 +30,43 @@ internal fun Route.userRoutes(userRepository: UserRepository) { .map { UserListItemDto(id = it.id, username = it.username) } call.respond(HttpStatusCode.OK, users) } + + put("/api/users/{id}/public-key") { + val principal = call.principal() + ?: return@put call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse(status = 401, message = "Unauthorized") + ) + val id = call.parameters["id"] + ?: return@put call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Missing id")) + if (id != principal.userId) { + return@put call.respond( + HttpStatusCode.Forbidden, + ErrorResponse(status = 403, message = "Forbidden") + ) + } + val request = call.receive() + if (request.publicKey.length > 10_000) { + return@put call.respond( + HttpStatusCode.PayloadTooLarge, + ErrorResponse(status = 413, message = "Public key exceeds maximum allowed length") + ) + } + userRepository.setPublicKey(id, request.publicKey) + webSocketManager.notifyKeyUpdated(excludeUserId = id, userId = id, publicKey = request.publicKey) + call.respond(HttpStatusCode.NoContent) + } + + get("/api/users/{id}/public-key") { + call.principal() + ?: return@get call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse(status = 401, message = "Unauthorized") + ) + val id = call.parameters["id"] + ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Missing id")) + val publicKey = userRepository.getPublicKey(id) + ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Public key not found")) + call.respond(HttpStatusCode.OK, PublicKeyResponse(publicKey = publicKey)) + } } diff --git a/server/src/main/kotlin/de/bollwerk/server/websocket/WebSocketManager.kt b/server/src/main/kotlin/de/bollwerk/server/websocket/WebSocketManager.kt index 70091ab..13ca631 100644 --- a/server/src/main/kotlin/de/bollwerk/server/websocket/WebSocketManager.kt +++ b/server/src/main/kotlin/de/bollwerk/server/websocket/WebSocketManager.kt @@ -54,6 +54,20 @@ internal class WebSocketManager { broadcast(receiverId, Json.encodeToString(payload)) } + suspend fun notifyKeyUpdated(excludeUserId: String, userId: String, publicKey: String) { + val payload = buildJsonObject { + put("type", "key_updated") + put("userId", userId) + put("publicKey", publicKey) + } + val message = Json.encodeToString(payload) + for ((sessionUserId, _) in sessions) { + if (sessionUserId != excludeUserId) { + broadcast(sessionUserId, message) + } + } + } + private suspend fun broadcast(userId: String, message: String) { val userSessions = sessions[userId] ?: return val toRemove = mutableListOf() diff --git a/server/src/main/resources/db/migration/V4__add_public_key_to_users.sql b/server/src/main/resources/db/migration/V4__add_public_key_to_users.sql new file mode 100644 index 0000000..72b5514 --- /dev/null +++ b/server/src/main/resources/db/migration/V4__add_public_key_to_users.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS public_key TEXT;