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

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