feat: E2EE Messaging mit Tink HPKE (X25519 + ChaCha20-Poly1305)

Closes #96

## App

- E2EEKeyManager: Tink HPKE Schlüsselpaar generieren, privaten Key
  via EncryptedSharedPreferences sichern, Nachrichten verschlüsseln
  und entschlüsseln (X25519 + ChaCha20-Poly1305)
- EnsureKeyPairUseCase: Keypair-Initialisierung beim App-Start;
  Public Key via HTTP PUT an Server übermitteln
- MainActivity: EnsureKeyPairUseCase.execute() in onCreate
- SettingsKey: E2EEPrivateKeyset + E2EEPublicKeyBase64 als SENSITIVE_KEYS
- MessageRepositoryImpl: sendMessage verschlüsselt Body mit Empfänger-
  Public-Key; eingehende Nachrichten werden lokal entschlüsselt und
  als Klartext in Room gespeichert; Public-Key-Cache (in-memory) +
  key_updated Handler
- WebSocketClient: KeyUpdated Event hinzugefügt
- WebSocketClientImpl: key_updated Frame parsen; Exception-Logging
- Tink 1.15.0 als neue Dependency

## Server

- V4 Flyway Migration: ALTER TABLE users ADD COLUMN public_key TEXT
- Tables.kt: publicKey Feld in Users-Objekt
- UserRepository: getPublicKey() / setPublicKey()
- UserRoutes: PUT /api/users/{id}/public-key (Auth + Owner-Check +
  Längenvalidierung ≤ 10.000 Zeichen) und
  GET /api/users/{id}/public-key
- WebSocketManager: notifyKeyUpdated() Broadcast an alle anderen
  verbundenen Clients
- MessageRepository: EncryptionService für message body bypassed –
  Server speichert E2EE-Ciphertext direkt (Zero-Knowledge)

## Tests

- E2EEKeyManagerTest: 5 Tests (Roundtrip, Nonce-Uniqueness,
  Wrong-Key, hasKeyPair)
- EnsureKeyPairUseCaseTest: 4 Tests (generate+upload, skip wenn
  vorhanden, kein Upload ohne UserId, kein Crash bei Server-Fehler)
- MessageRepositoryImplTest: 5 neue E2EE-Tests

## Docs

- docs/migration-guide.md: E2EE-Einschränkungen dokumentiert
  (Pending-Message Klartext in SQLite)

## Follow-up

- #105: E2EE Private Key – AndroidKeysetManager statt
  CleartextKeysetHandle (Security Hardening)
This commit is contained in:
Jens Reinemann 2026-05-18 00:22:28 +02:00
parent bed233521e
commit 8c0db56223
20 changed files with 835 additions and 32 deletions

View file

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

View file

@ -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 {

View file

@ -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<String, String>()
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<PublicKeyResponse>().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"
}
}

View file

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

View file

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

View file

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

View file

@ -20,6 +20,8 @@ internal sealed class SettingsKey<T>(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<T>(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<String> = SENSITIVE_KEYS.map { it.key }.toSet()

View file

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

View file

@ -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<WebSocketEvent>(extraBufferCapacity = 16)
override val events: SharedFlow<WebSocketEvent> = events2.asSharedFlow()
private val _connectionState = MutableStateFlow<de.bollwerk.app.data.sync.ConnectionState>(de.bollwerk.app.data.sync.ConnectionState.NotConfigured)
override val connectionState: kotlinx.coroutines.flow.StateFlow<de.bollwerk.app.data.sync.ConnectionState> = _connectionState
private val _connectionState = MutableStateFlow<de.bollwerk.app.data.sync.ConnectionState>(
de.bollwerk.app.data.sync.ConnectionState.NotConfigured
)
override val connectionState: kotlinx.coroutines.flow.StateFlow<de.bollwerk.app.data.sync.ConnectionState> =
_connectionState
override fun connect(serverUrl: String, accessToken: String) = Unit
override fun disconnect() = Unit
}
private fun buildFakeE2EEKeyManager(): E2EEKeyManager = mockk<E2EEKeyManager>(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,13 +137,21 @@ class MessageRepositoryImplTest {
set(SettingsKeys.AUTH_USER_ID, "user1")
set(SettingsKeys.AUTH_USERNAME, "Alice")
}
val engine = MockEngine {
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)
// When
@ -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,13 +203,21 @@ class MessageRepositoryImplTest {
set(SettingsKeys.SERVER_URL, "http://localhost:8080")
set(SettingsKeys.AUTH_ACCESS_TOKEN, "token")
}
val engine = MockEngine {
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)
// When
@ -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<E2EEKeyManager>(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<String>()
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<E2EEKeyManager>(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<E2EEKeyManager>(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<String>()
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<E2EEKeyManager>(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") }
)
}
}

View file

@ -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<String, String>()
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) }
}

View file

@ -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<String, String>()
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<String?> = MutableStateFlow(store[key])
override fun getAll(): Flow<List<SettingsEntity>> = 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<String> =
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<String>()
val e2eeKeyManager = mockk<E2EEKeyManager>().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<E2EEKeyManager>().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<String>()
val e2eeKeyManager = mockk<E2EEKeyManager>().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<E2EEKeyManager>().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()
}
}

View file

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

View file

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

View file

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

View file

@ -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=

View file

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

View file

@ -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],

View file

@ -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<UserPrincipal>()
?: 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<UserPrincipal>()
?: 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<SetPublicKeyRequest>()
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<UserPrincipal>()
?: 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))
}
}

View file

@ -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<WebSocketSession>()

View file

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS public_key TEXT;