android: secure token storage with keystore-backed encrypted prefs
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 15:15:28 +03:00
parent 7fcdc28015
commit c80ff650b2
6 changed files with 95 additions and 4 deletions

View File

@@ -318,3 +318,8 @@
- Added dedicated `Settings` and `Profile` routes/screens with mobile-safe insets and placeholder content.
- Removed direct logout action from chat list and moved logout action to `Settings`.
- Wired bottom navigation pills in chats to open `Settings` and `Profile`.
### Step 53 - Secure token storage (Keystore-backed)
- Added `EncryptedPrefsTokenRepository` backed by `EncryptedSharedPreferences` and Android `MasterKey` (Keystore).
- Switched DI token binding from DataStore token repository to encrypted shared preferences repository.
- Kept DataStore for non-token app settings and renamed preferences file to `messenger_preferences.preferences_pb`.

View File

@@ -85,6 +85,7 @@ dependencies {
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")

View File

@@ -0,0 +1,60 @@
package ru.daemonlord.messenger.core.token
import android.content.SharedPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import ru.daemonlord.messenger.di.TokenPrefs
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EncryptedPrefsTokenRepository @Inject constructor(
@TokenPrefs private val sharedPreferences: SharedPreferences,
) : TokenRepository {
private val tokensFlow = MutableStateFlow(readTokens())
override fun observeTokens(): Flow<TokenBundle?> = tokensFlow.asStateFlow()
override suspend fun getTokens(): TokenBundle? = tokensFlow.value
override suspend fun saveTokens(tokens: TokenBundle) {
sharedPreferences.edit()
.putString(ACCESS_TOKEN_KEY, tokens.accessToken)
.putString(REFRESH_TOKEN_KEY, tokens.refreshToken)
.putLong(SAVED_AT_KEY, tokens.savedAtMillis)
.apply()
tokensFlow.value = tokens
}
override suspend fun clearTokens() {
sharedPreferences.edit()
.remove(ACCESS_TOKEN_KEY)
.remove(REFRESH_TOKEN_KEY)
.remove(SAVED_AT_KEY)
.apply()
tokensFlow.value = null
}
private fun readTokens(): TokenBundle? {
val access = sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
val refresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
val savedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L)
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt <= 0L) {
return null
}
return TokenBundle(
accessToken = access,
refreshToken = refresh,
savedAtMillis = savedAt,
)
}
private companion object {
const val ACCESS_TOKEN_KEY = "access_token"
const val REFRESH_TOKEN_KEY = "refresh_token"
const val SAVED_AT_KEY = "tokens_saved_at"
}
}

View File

@@ -13,3 +13,7 @@ annotation class RefreshAuthApi
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TokenPrefs

View File

@@ -1,16 +1,19 @@
package ru.daemonlord.messenger.di
import android.content.Context
import android.content.SharedPreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.core.token.DataStoreTokenRepository
import ru.daemonlord.messenger.core.token.EncryptedPrefsTokenRepository
import ru.daemonlord.messenger.core.token.TokenRepository
import javax.inject.Singleton
@@ -24,13 +27,31 @@ object StorageModule {
@ApplicationContext context: Context,
): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("messenger_tokens.preferences_pb") }
produceFile = { context.preferencesDataStoreFile("messenger_preferences.preferences_pb") }
)
}
@Provides
@Singleton
@TokenPrefs
fun provideTokenSharedPreferences(
@ApplicationContext context: Context,
): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
"messenger_secure_tokens",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
@Provides
@Singleton
fun provideTokenRepository(
repository: DataStoreTokenRepository,
repository: EncryptedPrefsTokenRepository,
): TokenRepository = repository
}

View File

@@ -103,7 +103,7 @@
- [ ] Accessibility (TalkBack, dynamic type)
## 14. Безопасность
- [ ] Secure token storage (EncryptedSharedPrefs/Keystore)
- [x] Secure token storage (EncryptedSharedPrefs/Keystore)
- [ ] Certificate pinning (опционально)
- [ ] Root/emulator policy (опционально)
- [ ] Privacy-safe logging (без токенов/PII)