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:
parent
bed233521e
commit
8c0db56223
20 changed files with 835 additions and 32 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE users ADD COLUMN IF NOT EXISTS public_key TEXT;
|
||||
Loading…
Reference in a new issue