From c80ff650b27cc77fff976deab958805548bd390e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 15:15:28 +0300 Subject: [PATCH] android: secure token storage with keystore-backed encrypted prefs --- android/CHANGELOG.md | 5 ++ android/app/build.gradle.kts | 1 + .../token/EncryptedPrefsTokenRepository.kt | 60 +++++++++++++++++++ .../ru/daemonlord/messenger/di/Qualifiers.kt | 4 ++ .../daemonlord/messenger/di/StorageModule.kt | 27 ++++++++- docs/android-checklist.md | 2 +- 6 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/core/token/EncryptedPrefsTokenRepository.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 0310067..c0b47a5 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 514c752..959a476 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/token/EncryptedPrefsTokenRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/token/EncryptedPrefsTokenRepository.kt new file mode 100644 index 0000000..49c220f --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/token/EncryptedPrefsTokenRepository.kt @@ -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 = 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" + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/Qualifiers.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/Qualifiers.kt index 288b676..7ff021f 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/Qualifiers.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/Qualifiers.kt @@ -13,3 +13,7 @@ annotation class RefreshAuthApi @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class TokenPrefs diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/StorageModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/StorageModule.kt index 3166247..96b245c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/StorageModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/StorageModule.kt @@ -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 { 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 } diff --git a/docs/android-checklist.md b/docs/android-checklist.md index 895da2c..c95fe11 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -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)