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
This commit is contained in:
null 2026-06-19 22:06:52 -05:00
parent 8be7b7da0e
commit 700201bbd6
3 changed files with 65 additions and 23 deletions

View File

@ -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<String, Aead>()
@ -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"
}
}

View File

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

View File

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