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:
parent
90580ecb3e
commit
eb5bdd4b7b
9 changed files with 173 additions and 5 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
21
app/src/main/java/de/krisenvorrat/app/di/SecurityModule.kt
Normal file
21
app/src/main/java/de/krisenvorrat/app/di/SecurityModule.kt
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue