From 700201bbd6b9d9f08c67f1b4c68f8c2a226952ba Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 22:06:52 -0500 Subject: [PATCH] refactor: extract EncryptedSharedPreferences into SecurePreferencesFactory with auto-recovery (batch v0.2.21) - New SecurePreferencesFactory handles EncryptedSharedPreferences creation - Auto-resets and recreates on corruption (unreadable prefs) - CoupleKeyStore and SharedPreferencesLocalAnswerRepository use the factory - Removes duplicated EncryptedSharedPreferences boilerplate --- .../java/app/closer/crypto/CoupleKeyStore.kt | 19 +++---- .../data/local/SecurePreferencesFactory.kt | 55 +++++++++++++++++++ .../SharedPreferencesLocalAnswerRepository.kt | 14 +---- 3 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/app/closer/data/local/SecurePreferencesFactory.kt diff --git a/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt index 0b8e0171..4b1b131d 100644 --- a/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt +++ b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt @@ -2,8 +2,7 @@ package app.closer.crypto import android.content.Context import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys +import app.closer.data.local.SecurePreferencesFactory import com.google.crypto.tink.Aead import com.google.crypto.tink.CleartextKeysetHandle import com.google.crypto.tink.JsonKeysetReader @@ -24,16 +23,8 @@ import javax.inject.Singleton class CoupleKeyStore @Inject constructor( @ApplicationContext context: Context ) { - private val prefs: SharedPreferences = run { - val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - EncryptedSharedPreferences.create( - masterKeyAlias, - "couple_crypto_secure", - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - } + private val prefs: SharedPreferences = + SecurePreferencesFactory.encryptedSharedPreferences(context, PREFS_NAME) private val aeadCache = ConcurrentHashMap() @@ -110,4 +101,8 @@ class CoupleKeyStore @Inject constructor( val json = prefs.getString(key, null) ?: return null CleartextKeysetHandle.read(JsonKeysetReader.withString(json)) }.getOrNull() + + private companion object { + const val PREFS_NAME = "couple_crypto_secure" + } } diff --git a/app/src/main/java/app/closer/data/local/SecurePreferencesFactory.kt b/app/src/main/java/app/closer/data/local/SecurePreferencesFactory.kt new file mode 100644 index 00000000..f9481de6 --- /dev/null +++ b/app/src/main/java/app/closer/data/local/SecurePreferencesFactory.kt @@ -0,0 +1,55 @@ +package app.closer.data.local + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import java.security.KeyStore + +object SecurePreferencesFactory { + private const val TAG = "SecurePreferences" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val MASTER_KEY_ALIAS = "_androidx_security_master_key_" + + fun encryptedSharedPreferences( + context: Context, + prefsName: String + ): SharedPreferences { + val appContext = context.applicationContext + return runCatching { + create(appContext, prefsName) + }.getOrElse { firstError -> + Log.w(TAG, "Resetting unreadable encrypted preferences: $prefsName", firstError) + reset(appContext, prefsName) + create(appContext, prefsName) + } + } + + private fun create(context: Context, prefsName: String): SharedPreferences { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + return EncryptedSharedPreferences.create( + prefsName, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + private fun reset(context: Context, prefsName: String) { + context.deleteSharedPreferences(prefsName) + context.deleteSharedPreferences(MASTER_KEY_ALIAS) + runCatching { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + if (keyStore.containsAlias(prefsName)) { + keyStore.deleteEntry(prefsName) + } + if (keyStore.containsAlias(MASTER_KEY_ALIAS)) { + keyStore.deleteEntry(MASTER_KEY_ALIAS) + } + }.onFailure { error -> + Log.w(TAG, "Could not delete Android Keystore master key.", error) + } + } +} diff --git a/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt b/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt index a6404424..36116720 100644 --- a/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt +++ b/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt @@ -2,8 +2,7 @@ package app.closer.data.repository import android.content.Context import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys +import app.closer.data.local.SecurePreferencesFactory import app.closer.domain.model.LocalAnswer import app.closer.domain.repository.LocalAnswerRepository import dagger.hilt.android.qualifiers.ApplicationContext @@ -24,15 +23,7 @@ class SharedPreferencesLocalAnswerRepository @Inject constructor( // Remove legacy plaintext file on first migration context.deleteSharedPreferences("local_answers") - // In security-crypto 1.0.0, EncryptedSharedPreferences.create takes (alias, name, context, ...) - val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - EncryptedSharedPreferences.create( - masterKeyAlias, // alias (first param) - "local_answers_secure", // name (second param) - context, // context (third param) - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) + SecurePreferencesFactory.encryptedSharedPreferences(context, PREFS_NAME) } private val answers = MutableStateFlow(readAnswers()) @@ -132,5 +123,6 @@ class SharedPreferencesLocalAnswerRepository @Inject constructor( private companion object { const val KEY_ANSWERS = "answers" + const val PREFS_NAME = "local_answers_secure" } }