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
This commit is contained in:
Jens Reinemann 2026-05-17 02:55:43 +02:00
parent 90580ecb3e
commit eb5bdd4b7b
9 changed files with 173 additions and 5 deletions

View file

@ -73,6 +73,9 @@ dependencies {
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
// Security
implementation(libs.androidx.security.crypto)
// Navigation // Navigation
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)

View file

@ -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.ItemEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.data.db.entity.SettingsEntity 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.parseAgeGroupsFromJson
import de.krisenvorrat.app.domain.model.totalDailyKcal import de.krisenvorrat.app.domain.model.totalDailyKcal
import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.ImportExportRepository
@ -52,6 +53,7 @@ internal class ImportExportRepositoryImpl @Inject constructor(
val locations = locationDao.getAll().first() val locations = locationDao.getAll().first()
val items = itemDao.getAll().first() val items = itemDao.getAll().first()
val settings = settingsDao.getAll().first() val settings = settingsDao.getAll().first()
.filter { it.key !in SettingsKeys.SENSITIVE_KEYS }
return InventoryDto( return InventoryDto(
categories = categories.map { CategoryDto(id = it.id, name = it.name) }, categories = categories.map { CategoryDto(id = it.id, name = it.name) },
@ -113,7 +115,11 @@ internal class ImportExportRepositoryImpl @Inject constructor(
lastUpdated = item.lastUpdated 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) }
)
} }
} }

View file

@ -2,6 +2,8 @@ package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.SettingsDao import de.krisenvorrat.app.data.db.dao.SettingsDao
import de.krisenvorrat.app.data.db.entity.SettingsEntity 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 de.krisenvorrat.app.domain.repository.SettingsRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -9,14 +11,29 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
internal class SettingsRepositoryImpl @Inject constructor( internal class SettingsRepositoryImpl @Inject constructor(
private val dao: SettingsDao private val dao: SettingsDao,
private val secureTokenStorage: SecureTokenStorage
) : SettingsRepository { ) : SettingsRepository {
override suspend fun getValue(key: String): String? = 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) = 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<String?> = dao.observeValue(key) override fun observeValue(key: String): Flow<String?> = dao.observeValue(key)

View file

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

View file

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

View file

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

View file

@ -11,4 +11,12 @@ internal object SettingsKeys {
const val AUTH_USER_ID = "auth_user_id" const val AUTH_USER_ID = "auth_user_id"
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp" const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
const val OPENAI_API_KEY = "openai_api_key" const val OPENAI_API_KEY = "openai_api_key"
val SENSITIVE_KEYS: Set<String> = setOf(
AUTH_ACCESS_TOKEN,
AUTH_REFRESH_TOKEN,
AUTH_USERNAME,
AUTH_USER_ID,
OPENAI_API_KEY
)
} }

View file

@ -2,6 +2,8 @@ package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.SettingsDao import de.krisenvorrat.app.data.db.dao.SettingsDao
import de.krisenvorrat.app.data.db.entity.SettingsEntity 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -33,10 +35,25 @@ private class FakeSettingsDao : SettingsDao {
} }
} }
private class FakeSecureTokenStorage : SecureTokenStorage {
private val store = mutableMapOf<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)
}
}
class SettingsRepositoryImplTest { class SettingsRepositoryImplTest {
private val fakeDao = FakeSettingsDao() private val fakeDao = FakeSettingsDao()
private val repository = SettingsRepositoryImpl(fakeDao) private val fakeSecureStorage = FakeSecureTokenStorage()
private val repository = SettingsRepositoryImpl(fakeDao, fakeSecureStorage)
@Test @Test
fun test_setValue_withNewKey_valueCanBeRetrieved() = runBlocking { 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 == "theme" && it.value == "dark" })
assertTrue(result.any { it.key == "language" && it.value == "de" }) 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))
}
}
} }

View file

@ -12,6 +12,7 @@ composeBom = "2025.01.00"
hilt = "2.54" hilt = "2.54"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
room = "2.6.1" room = "2.6.1"
securityCrypto = "1.1.0-alpha06"
navigationCompose = "2.8.5" navigationCompose = "2.8.5"
kotlinxSerialization = "1.7.3" kotlinxSerialization = "1.7.3"
kotlinxCoroutines = "1.9.0" 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-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", 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-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" } 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" } 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" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }