From eb5bdd4b7bba22aee9159cc7373122dd9670110b Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sun, 17 May 2026 02:55:43 +0200 Subject: [PATCH] feat(security): JWT-Tokens in EncryptedSharedPreferences speichern Sensitive Keys (auth_access_token, auth_refresh_token, auth_username, auth_user_id, openai_api_key) werden jetzt ueber EncryptedSharedPreferences gespeichert statt als Klartext in der Room-Settings-Tabelle. Neue Dateien: - SecureTokenStorage: Interface fuer sichere Key-Value-Speicherung - EncryptedPrefsTokenStorage: Implementierung mit AndroidX Security Crypto - SecurityModule: Hilt-Provider fuer SecureTokenStorage Aenderungen: - SettingsKeys: SENSITIVE_KEYS Set definiert welche Keys verschluesselt werden - SettingsRepositoryImpl: Routet sensitive Keys an SecureTokenStorage, nicht-sensitive weiterhin an Room DAO - ImportExportRepositoryImpl: Filtert sensitive Keys bei Export und Import - SettingsRepositoryImplTest: 4 neue Tests fuer Secure-Storage-Routing Closes #72 --- app/build.gradle.kts | 3 + .../data/export/ImportExportRepositoryImpl.kt | 8 +- .../data/repository/SettingsRepositoryImpl.kt | 23 +++++- .../security/EncryptedPrefsTokenStorage.kt | 33 +++++++++ .../app/data/security/SecureTokenStorage.kt | 7 ++ .../de/krisenvorrat/app/di/SecurityModule.kt | 21 ++++++ .../app/domain/model/SettingsKeys.kt | 8 ++ .../repository/SettingsRepositoryImplTest.kt | 73 ++++++++++++++++++- gradle/libs.versions.toml | 2 + 9 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/de/krisenvorrat/app/data/security/EncryptedPrefsTokenStorage.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/security/SecureTokenStorage.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/di/SecurityModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 13e8ce6..692e031 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,6 +73,9 @@ dependencies { implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) + // Security + implementation(libs.androidx.security.crypto) + // Navigation implementation(libs.androidx.navigation.compose) diff --git a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt index 6874838..0d89dbb 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt @@ -8,6 +8,7 @@ import de.krisenvorrat.app.data.db.entity.CategoryEntity import de.krisenvorrat.app.data.db.entity.ItemEntity import de.krisenvorrat.app.data.db.entity.LocationEntity import de.krisenvorrat.app.data.db.entity.SettingsEntity +import de.krisenvorrat.app.domain.model.SettingsKeys import de.krisenvorrat.app.domain.model.parseAgeGroupsFromJson import de.krisenvorrat.app.domain.model.totalDailyKcal import de.krisenvorrat.app.domain.repository.ImportExportRepository @@ -52,6 +53,7 @@ internal class ImportExportRepositoryImpl @Inject constructor( val locations = locationDao.getAll().first() val items = itemDao.getAll().first() val settings = settingsDao.getAll().first() + .filter { it.key !in SettingsKeys.SENSITIVE_KEYS } return InventoryDto( categories = categories.map { CategoryDto(id = it.id, name = it.name) }, @@ -113,7 +115,11 @@ internal class ImportExportRepositoryImpl @Inject constructor( lastUpdated = item.lastUpdated ) }) - settingsDao.upsertAll(dto.settings.map { SettingsEntity(key = it.key, value = it.value) }) + settingsDao.upsertAll( + dto.settings + .filter { it.key !in SettingsKeys.SENSITIVE_KEYS } + .map { SettingsEntity(key = it.key, value = it.value) } + ) } } diff --git a/app/src/main/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImpl.kt index 64ab87a..41c8cdc 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImpl.kt @@ -2,6 +2,8 @@ package de.krisenvorrat.app.data.repository import de.krisenvorrat.app.data.db.dao.SettingsDao import de.krisenvorrat.app.data.db.entity.SettingsEntity +import de.krisenvorrat.app.data.security.SecureTokenStorage +import de.krisenvorrat.app.domain.model.SettingsKeys import de.krisenvorrat.app.domain.repository.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -9,14 +11,29 @@ import kotlinx.coroutines.withContext import javax.inject.Inject internal class SettingsRepositoryImpl @Inject constructor( - private val dao: SettingsDao + private val dao: SettingsDao, + private val secureTokenStorage: SecureTokenStorage ) : SettingsRepository { override suspend fun getValue(key: String): String? = - withContext(Dispatchers.IO) { dao.getValue(key) } + if (key in SettingsKeys.SENSITIVE_KEYS) { + withContext(Dispatchers.IO) { secureTokenStorage.get(key) } + } else { + withContext(Dispatchers.IO) { dao.getValue(key) } + } override suspend fun setValue(key: String, value: String) = - withContext(Dispatchers.IO) { dao.upsert(SettingsEntity(key = key, value = value)) } + if (key in SettingsKeys.SENSITIVE_KEYS) { + withContext(Dispatchers.IO) { + if (value.isBlank()) { + secureTokenStorage.remove(key) + } else { + secureTokenStorage.set(key, value) + } + } + } else { + withContext(Dispatchers.IO) { dao.upsert(SettingsEntity(key = key, value = value)) } + } override fun observeValue(key: String): Flow = dao.observeValue(key) diff --git a/app/src/main/java/de/krisenvorrat/app/data/security/EncryptedPrefsTokenStorage.kt b/app/src/main/java/de/krisenvorrat/app/data/security/EncryptedPrefsTokenStorage.kt new file mode 100644 index 0000000..ad7e19f --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/security/EncryptedPrefsTokenStorage.kt @@ -0,0 +1,33 @@ +package de.krisenvorrat.app.data.security + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +internal class EncryptedPrefsTokenStorage(context: Context) : SecureTokenStorage { + + private val prefs: SharedPreferences by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "krisenvorrat_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + override fun get(key: String): String? = + prefs.getString(key, null) + + override fun set(key: String, value: String) { + prefs.edit().putString(key, value).apply() + } + + override fun remove(key: String) { + prefs.edit().remove(key).apply() + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/security/SecureTokenStorage.kt b/app/src/main/java/de/krisenvorrat/app/data/security/SecureTokenStorage.kt new file mode 100644 index 0000000..7ede91b --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/security/SecureTokenStorage.kt @@ -0,0 +1,7 @@ +package de.krisenvorrat.app.data.security + +internal interface SecureTokenStorage { + fun get(key: String): String? + fun set(key: String, value: String) + fun remove(key: String) +} diff --git a/app/src/main/java/de/krisenvorrat/app/di/SecurityModule.kt b/app/src/main/java/de/krisenvorrat/app/di/SecurityModule.kt new file mode 100644 index 0000000..5c03f6d --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/di/SecurityModule.kt @@ -0,0 +1,21 @@ +package de.krisenvorrat.app.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import de.krisenvorrat.app.data.security.EncryptedPrefsTokenStorage +import de.krisenvorrat.app.data.security.SecureTokenStorage +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object SecurityModule { + + @Provides + @Singleton + fun provideSecureTokenStorage(@ApplicationContext context: Context): SecureTokenStorage = + EncryptedPrefsTokenStorage(context) +} diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt index 133f30c..be9f6c6 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt @@ -11,4 +11,12 @@ internal object SettingsKeys { const val AUTH_USER_ID = "auth_user_id" const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp" const val OPENAI_API_KEY = "openai_api_key" + + val SENSITIVE_KEYS: Set = setOf( + AUTH_ACCESS_TOKEN, + AUTH_REFRESH_TOKEN, + AUTH_USERNAME, + AUTH_USER_ID, + OPENAI_API_KEY + ) } diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImplTest.kt index f27d4ee..13f0b15 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/SettingsRepositoryImplTest.kt @@ -2,6 +2,8 @@ package de.krisenvorrat.app.data.repository import de.krisenvorrat.app.data.db.dao.SettingsDao import de.krisenvorrat.app.data.db.entity.SettingsEntity +import de.krisenvorrat.app.data.security.SecureTokenStorage +import de.krisenvorrat.app.domain.model.SettingsKeys import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -33,10 +35,25 @@ private class FakeSettingsDao : SettingsDao { } } +private class FakeSecureTokenStorage : SecureTokenStorage { + private val store = mutableMapOf() + + 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) + } +} + class SettingsRepositoryImplTest { private val fakeDao = FakeSettingsDao() - private val repository = SettingsRepositoryImpl(fakeDao) + private val fakeSecureStorage = FakeSecureTokenStorage() + private val repository = SettingsRepositoryImpl(fakeDao, fakeSecureStorage) @Test fun test_setValue_withNewKey_valueCanBeRetrieved() = runBlocking { @@ -87,4 +104,58 @@ class SettingsRepositoryImplTest { assertTrue(result.any { it.key == "theme" && it.value == "dark" }) assertTrue(result.any { it.key == "language" && it.value == "de" }) } + + @Test + fun test_setValue_withSensitiveKey_routesToSecureStorage() = runBlocking { + // Given + val token = "eyJhbGciOiJIUzI1NiJ9.test" + + // When + repository.setValue(SettingsKeys.AUTH_ACCESS_TOKEN, token) + + // Then + assertEquals(token, repository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)) + assertEquals(token, fakeSecureStorage.get(SettingsKeys.AUTH_ACCESS_TOKEN)) + assertNull(fakeDao.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)) + } + + @Test + fun test_setValue_withNonSensitiveKey_routesToDao() = runBlocking { + // Given + val value = "https://example.com" + + // When + repository.setValue(SettingsKeys.SERVER_URL, value) + + // Then + assertEquals(value, repository.getValue(SettingsKeys.SERVER_URL)) + assertEquals(value, fakeDao.getValue(SettingsKeys.SERVER_URL)) + assertNull(fakeSecureStorage.get(SettingsKeys.SERVER_URL)) + } + + @Test + fun test_setValue_withBlankSensitiveValue_removesFromSecureStorage() = runBlocking { + // Given + repository.setValue(SettingsKeys.AUTH_ACCESS_TOKEN, "token123") + + // When + repository.setValue(SettingsKeys.AUTH_ACCESS_TOKEN, "") + + // Then + assertNull(repository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)) + } + + @Test + fun test_setValue_allSensitiveKeys_routeToSecureStorage() = runBlocking { + // Given / When + for (key in SettingsKeys.SENSITIVE_KEYS) { + repository.setValue(key, "value_$key") + } + + // Then + for (key in SettingsKeys.SENSITIVE_KEYS) { + assertEquals("value_$key", fakeSecureStorage.get(key)) + assertNull(fakeDao.getValue(key)) + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a86cc79..77a127a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ composeBom = "2025.01.00" hilt = "2.54" hiltNavigationCompose = "1.2.0" room = "2.6.1" +securityCrypto = "1.1.0-alpha06" navigationCompose = "2.8.5" kotlinxSerialization = "1.7.3" kotlinxCoroutines = "1.9.0" @@ -47,6 +48,7 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }