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)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
// Security
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
// Navigation
|
||||
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.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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
|
||||
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.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<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 {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue