From fbdf4203dd4889b6902c87dba98a2a4c570b290e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?minhnguyen=E3=83=9F=E3=83=B3?= Date: Tue, 18 Aug 2026 15:37:14 +0900 Subject: [PATCH 1/6] Recover from an unreadable encrypted preference keyset EncryptedSharedPreferences.create() was called straight from a lazy with no error handling. When the AndroidKeyStore master key no longer matches the Tink keyset stored in private_pref -- a device restore, a key invalidation -- Tink throws AEADBadTagException and every encrypt and decrypt through this class fails from then on, permanently. Open the store through a three step recovery instead: retry after clearing the keyset, then after replacing the master key. Only failures that positively identify lost key material trigger it, so a locked device or an unavailable keystore daemon still surfaces as an error rather than discarding readable data. Values written with VERSION_AES_KEY_ENCRYPTED_PREFERENCE before a reset are gone, so report them as UnrecoverableCiphertextException and expose wasEncryptedPreferenceReset() for callers that re-derive from a backup. Co-Authored-By: Claude Opus 5 (1M context) --- securepreferences/build.gradle | 6 +- .../SecureStringEncrypter.kt | 126 +++++++++++++++++- .../bitcoin/securepreferences/exceptions.kt | 20 +++ 3 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt diff --git a/securepreferences/build.gradle b/securepreferences/build.gradle index bd817e1..46ad6ac 100644 --- a/securepreferences/build.gradle +++ b/securepreferences/build.gradle @@ -2,7 +2,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' group = 'com.github.Bitcoin-com' -version = '1.2.4' +version = '1.2.6' android { compileSdkVersion 32 @@ -10,8 +10,8 @@ android { defaultConfig { targetSdkVersion 32 minSdkVersion 23 - versionCode 10204 - versionName "1.2.4" + versionCode 10206 + versionName "1.2.6" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt index d01ae0f..eb8c9dc 100644 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt @@ -3,12 +3,16 @@ package com.bitcoin.securepreferences import android.app.KeyguardManager import android.content.Context import android.content.SharedPreferences +import android.security.keystore.KeyPermanentlyInvalidatedException import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKeys import org.json.JSONObject import org.spongycastle.util.encoders.Base64 +import java.security.KeyStore +import java.security.UnrecoverableKeyException import java.util.* +import javax.crypto.BadPaddingException // https://doridori.github.io/android-security-the-forgetful-keystore/#sthash.UZTvjDTP.ncWnyt7V.dpbs @@ -69,10 +73,59 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { return encryptStringUsingKeystoreAes(value) } - val encryptedSharedPreference: SharedPreferences by lazy { + val encryptedSharedPreference: SharedPreferences by lazy { openEncryptedSharedPreference() } + + /** + * True once the encrypted preference store has been reset because its KeyStore key was lost. + * Everything written with [VERSION_AES_KEY_ENCRYPTED_PREFERENCE] before that is unreadable, so + * callers should re-derive it from a backup and then call [acknowledgeEncryptedPreferenceReset]. + */ + fun wasEncryptedPreferenceReset(): Boolean = + stateSharedPreference.getBoolean(KEY_WAS_RESET, false) + + fun acknowledgeEncryptedPreferenceReset() { + stateSharedPreference.edit().remove(KEY_WAS_RESET).commit() + } + + /** + * Opens the encrypted preference store, recovering when the KeyStore key that wraps its Tink + * keyset no longer matches it — the state a device restore or a key invalidation leaves behind. + * Both keysets and every data key live in [ENCRYPTED_PREFERENCE_FILE], so a mismatch makes all + * of it unreadable for good and the only way forward is to discard it and start again. + */ + private fun openEncryptedSharedPreference(): SharedPreferences = + synchronized(encryptedPreferenceLock) { + try { + return createEncryptedSharedPreference() + } catch (e: Exception) { + if (!e.isUnrecoverableKeyStoreFailure()) throw e + Log.w(TAG, "Encrypted preference keyset cannot be unwrapped, discarding it", e) + } + + clearEncryptedPreferenceFile() + try { + return createEncryptedSharedPreference().also { recordReset() } + } catch (e: Exception) { + if (!e.isUnrecoverableKeyStoreFailure()) throw e + Log.w(TAG, "Encrypted preference keyset still unusable, replacing master key", e) + } + + // The master key itself is unusable, not just the keyset it wrapped. + deleteAndroidxMasterKey() + clearEncryptedPreferenceFile() + return try { + createEncryptedSharedPreference().also { recordReset() } + } catch (e: Exception) { + throw EncryptedPreferenceUnavailableException( + "Unable to open the encrypted preference store.", e + ) + } + } + + private fun createEncryptedSharedPreference(): SharedPreferences { val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - EncryptedSharedPreferences.create( - "private_pref", + return EncryptedSharedPreferences.create( + ENCRYPTED_PREFERENCE_FILE, masterKeyAlias, mApplicationContext, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, @@ -80,6 +133,32 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { ) } + private fun clearEncryptedPreferenceFile() { + mApplicationContext + .getSharedPreferences(ENCRYPTED_PREFERENCE_FILE, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + private fun deleteAndroidxMasterKey() { + try { + val keyStore: KeyStore = KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE) + keyStore.load(null, null) + keyStore.deleteEntry(ANDROIDX_MASTER_KEY_ALIAS) + } catch (e: Exception) { + // Nothing more we can do; the create retry that follows reports the real failure. + Log.w(TAG, "Unable to delete the androidx master key", e) + } + } + + private val stateSharedPreference: SharedPreferences + get() = mApplicationContext.getSharedPreferences(STATE_PREFERENCE_FILE, Context.MODE_PRIVATE) + + private fun recordReset() { + stateSharedPreference.edit().putBoolean(KEY_WAS_RESET, true).commit() + } + private fun encryptStringUsingAesThenEncryptedPreference(value: String): String { val aesEncrypted: AesEncryptionResult = encryptUsingAesWithoutKeystore(value) val encrypted = JSONObject() @@ -176,7 +255,10 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { val base64Key = sharedPreferences.getString(keyRef, null) if (base64Key == null) { - throw Exception("Unable to find key: $base64Key") + // Reached whenever the store has been reset out from under existing ciphertext. + throw UnrecoverableCiphertextException( + "Data key $keyRef is no longer in the encrypted preference store." + ) } val aesKey = Base64.decode(base64Key) @@ -209,5 +291,39 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { const val VERSION_AES_KEY_STORE_RSA: Int = 2 const val VERSION_KEY_STORE_AES: Int = 3 const val VERSION_AES_KEY_ENCRYPTED_PREFERENCE = 4 + + private const val ENCRYPTED_PREFERENCE_FILE: String = "private_pref" + private const val STATE_PREFERENCE_FILE: String = "securepreferences_state" + private const val KEY_WAS_RESET: String = "encrypted_preference_was_reset" + private const val PROVIDER_ANDROID_KEY_STORE: String = "AndroidKeyStore" + + // androidx.security.crypto.MasterKeys.MASTER_KEY_ALIAS is package private. + private const val ANDROIDX_MASTER_KEY_ALIAS: String = "_androidx_security_master_key_" + + // Every instance shares one preference file, so recovery has to be process wide. + private val encryptedPreferenceLock = Any() + } +} + +private const val MAX_CAUSE_DEPTH: Int = 20 + +/** + * True when the KeyStore key that wrapped the keyset is gone or no longer matches it. Transient + * KeyStore errors — a locked device, an unavailable keystore daemon — must not match, or recovery + * would discard data that is still readable. + */ +internal fun Throwable.isUnrecoverableKeyStoreFailure(): Boolean { + var cause: Throwable? = this + var depth = 0 + while (cause != null && depth++ < MAX_CAUSE_DEPTH) { + // AEADBadTagException extends BadPaddingException; either means the blob failed to decrypt. + if (cause is BadPaddingException || + cause is UnrecoverableKeyException || + cause is KeyPermanentlyInvalidatedException + ) { + return true + } + cause = cause.cause } -} \ No newline at end of file + return false +} diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt new file mode 100644 index 0000000..4acb831 --- /dev/null +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt @@ -0,0 +1,20 @@ +package com.bitcoin.securepreferences + +/** + * Ciphertext that can never be decrypted on this device again, because the key material that + * protected it is gone. Callers should re-derive the value from a backup rather than retry. + */ +open class UnrecoverableCiphertextException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) + +/** + * The encrypted preference store could not be opened even after being reset, so nothing can be + * encrypted or decrypted through it. Unlike [UnrecoverableCiphertextException] this may clear up, + * for example once a device with a misbehaving KeyStore is rebooted. + */ +class EncryptedPreferenceUnavailableException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) From 33f0080b6a2585625cfd9c9c6fd9ba53bd93e729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?minhnguyen=E3=83=9F=E3=83=B3?= Date: Tue, 18 Aug 2026 16:44:38 +0900 Subject: [PATCH 2/6] Add AES-GCM v5 encryption and typed key-loss recovery --- build.gradle | 7 +- gradle/wrapper/gradle-wrapper.properties | 2 +- securepreferences/build.gradle | 5 +- .../SecurePreferencesTest.kt | 75 ++++++++++- .../src/main/AndroidManifest.xml | 3 +- .../securepreferences/SecurePreferences.kt | 3 +- .../SecureStringEncrypter.kt | 20 ++- .../com/bitcoin/securepreferences/aes_gcm.kt | 120 ++++++++++++++++++ .../bitcoin/securepreferences/exceptions.kt | 10 ++ 9 files changed, 231 insertions(+), 14 deletions(-) create mode 100644 securepreferences/src/main/java/com/bitcoin/securepreferences/aes_gcm.kt diff --git a/build.gradle b/build.gradle index 5b0e1ef..817bdff 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,13 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.7.10' + ext.kotlin_version = '1.8.22' repositories { google() - jcenter() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.4' - classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1' + classpath 'com.android.tools.build:gradle:8.1.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -19,7 +17,6 @@ buildscript { allprojects { repositories { google() - jcenter() mavenCentral() maven { url 'https://jitpack.io' } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ffed3a2..da1db5f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/securepreferences/build.gradle b/securepreferences/build.gradle index 46ad6ac..6fd239c 100644 --- a/securepreferences/build.gradle +++ b/securepreferences/build.gradle @@ -5,7 +5,8 @@ group = 'com.github.Bitcoin-com' version = '1.2.6' android { - compileSdkVersion 32 + namespace 'com.bitcoin.securepreferences' + compileSdkVersion 34 defaultConfig { targetSdkVersion 32 @@ -45,7 +46,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "androidx.security:security-crypto:1.0.0" + implementation "androidx.security:security-crypto:1.1.0" androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' } diff --git a/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt b/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt index bfdf952..5876060 100644 --- a/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt +++ b/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt @@ -2,6 +2,7 @@ package com.bitcoin.securepreferences import android.content.Context import android.content.SharedPreferences +import android.util.Base64 import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.test.core.app.ApplicationProvider @@ -12,6 +13,7 @@ import org.junit.Test import org.junit.Assert.* import org.junit.runner.RunWith +import org.json.JSONObject // This crashes - maybe because there is no app for the context? @@ -106,4 +108,75 @@ class SecurePreferencesTest { assertEquals(retrieved, "value2") } -} \ No newline at end of file + + @Test + fun defaultEncryptionUsesVersion5AndRoundTrips() { + val encrypter = SecureStringEncrypter( + ApplicationProvider.getApplicationContext(), + "version5-round-trip" + ) + + val ciphertext = encrypter.encryptString("secret") + + assertEquals(SecureStringEncrypter.VERSION_KEY_STORE_AES_GCM, encrypter.getEncryptionType(ciphertext)) + assertEquals("secret", encrypter.decryptString(ciphertext)) + } + + @Test + fun missingVersion5KeyThrowsTypedKeyLoss() { + val namespace = "version5-missing-key" + val encrypter = SecureStringEncrypter(ApplicationProvider.getApplicationContext(), namespace) + val ciphertext = encrypter.encryptString("secret") + deleteAesGcmEncryptionKeyFromKeyStoreIfExists(namespace) + + try { + encrypter.decryptString(ciphertext) + fail("Expected LocalEncryptionKeyLostException") + } catch (_: LocalEncryptionKeyLostException) { + // Expected: callers can route this failure into credential recovery. + } + } + + @Test + fun modifiedVersion5CiphertextThrowsTypedKeyLoss() { + val encrypter = SecureStringEncrypter( + ApplicationProvider.getApplicationContext(), + "version5-modified-ciphertext" + ) + val container = JSONObject(encrypter.encryptString("secret")) + val encrypted = container.getJSONObject("encrypted") + val bytes = Base64.decode(encrypted.getString("ct"), Base64.NO_WRAP) + bytes[0] = (bytes[0].toInt() xor 1).toByte() + encrypted.put("ct", Base64.encodeToString(bytes, Base64.NO_WRAP)) + + try { + encrypter.decryptString(container.toString()) + fail("Expected LocalEncryptionKeyLostException") + } catch (_: LocalEncryptionKeyLostException) { + // Expected: AES-GCM authenticates the ciphertext before returning plaintext. + } + } + + @Test + fun missingLegacyVersion4DataKeyThrowsTypedKeyLoss() { + val encrypter = SecureStringEncrypter( + ApplicationProvider.getApplicationContext(), + "version4-missing-data-key" + ) + val ciphertext = encrypter.encryptString( + "secret", + versionOverride = SecureStringEncrypter.VERSION_AES_KEY_ENCRYPTED_PREFERENCE + ) + val keyReference = JSONObject(ciphertext) + .getJSONObject("encrypted") + .getString("key") + encrypter.encryptedSharedPreference.edit().remove(keyReference).commit() + + try { + encrypter.decryptString(ciphertext) + fail("Expected LocalEncryptionKeyLostException") + } catch (_: LocalEncryptionKeyLostException) { + // Expected: version 4 remains readable, but lost data keys get the typed recovery signal. + } + } +} diff --git a/securepreferences/src/main/AndroidManifest.xml b/securepreferences/src/main/AndroidManifest.xml index bf9d2fe..2a14e6b 100644 --- a/securepreferences/src/main/AndroidManifest.xml +++ b/securepreferences/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt index a97457c..21b04a5 100644 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt @@ -34,6 +34,7 @@ class SecurePreferences(context: Context, private val namespace: String) { fun clear() { editor.clear() deleteAesEncryptionKeyFromKeyStoreIfExists(namespace) + deleteAesGcmEncryptionKeyFromKeyStoreIfExists(namespace) deleteRsaEncryptionKeyFromKeyStoreIfExists(namespace) } @@ -89,4 +90,4 @@ class SecurePreferences(context: Context, private val namespace: String) { return null } -} \ No newline at end of file +} diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt index eb8c9dc..54c77dd 100644 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt @@ -55,6 +55,7 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { VERSION_AES_KEY_ENCRYPTED_PREFERENCE -> encryptStringUsingAesThenEncryptedPreference( value ) + VERSION_KEY_STORE_AES_GCM -> encryptStringUsingKeystoreAesGcm(value) VERSION_AES_KEY_STORE_RSA -> encryptStringUsingAesThenKeystoreRsa(value) else -> encryptString(value) } @@ -70,7 +71,7 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { @Synchronized fun encryptString(value: String): String { - return encryptStringUsingKeystoreAes(value) + return encryptStringUsingKeystoreAesGcm(value) } val encryptedSharedPreference: SharedPreferences by lazy { openEncryptedSharedPreference() } @@ -193,6 +194,13 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { return container.toString() } + private fun encryptStringUsingKeystoreAesGcm(value: String): String { + return JSONObject() + .put(JSON_VERSION, VERSION_KEY_STORE_AES_GCM) + .put(JSON_ENCRYPTED, encryptUsingAesGcmWithKeyStore(value, namespace)) + .toString() + } + private fun encryptStringUsingKeystoreAes(value: String): String { val encrypted: JSONObject = encryptUsingAesWithKeystore(value, namespace) //Log.d(TAG, "aesEncrypted: ${encrypted}") @@ -240,6 +248,13 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { ?: throw Exception("Encrypted value for encrypted data version $version not found.") return decryptStringEncryptedUsingAesEncryptedSharedPreference(encrypted) } + VERSION_KEY_STORE_AES_GCM -> { + val encrypted = parsed.optJSONObject(JSON_ENCRYPTED) + ?: throw UnrecoverableCiphertextException( + "Encrypted value for encrypted data version $version not found." + ) + return decryptUsingAesGcmWithKeyStore(encrypted, namespace) + } else -> throw Exception("Version of encrypted data not recognised.") } @@ -256,7 +271,7 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { if (base64Key == null) { // Reached whenever the store has been reset out from under existing ciphertext. - throw UnrecoverableCiphertextException( + throw LocalEncryptionKeyLostException( "Data key $keyRef is no longer in the encrypted preference store." ) } @@ -291,6 +306,7 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { const val VERSION_AES_KEY_STORE_RSA: Int = 2 const val VERSION_KEY_STORE_AES: Int = 3 const val VERSION_AES_KEY_ENCRYPTED_PREFERENCE = 4 + const val VERSION_KEY_STORE_AES_GCM: Int = 5 private const val ENCRYPTED_PREFERENCE_FILE: String = "private_pref" private const val STATE_PREFERENCE_FILE: String = "securepreferences_state" diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/aes_gcm.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/aes_gcm.kt new file mode 100644 index 0000000..b7571b3 --- /dev/null +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/aes_gcm.kt @@ -0,0 +1,120 @@ +package com.bitcoin.securepreferences + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.util.Base64 +import org.json.JSONObject +import java.security.KeyStore +import java.security.UnrecoverableKeyException +import javax.crypto.AEADBadTagException +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +private const val AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding" +private const val AES_GCM_KEY_ALIAS_SUFFIX = ".aes_gcm_v5" +private const val AES_GCM_KEY_SIZE_BITS = 256 +private const val AES_GCM_TAG_SIZE_BITS = 128 +private const val AES_GCM_IV_SIZE_BYTES = 12 +private const val AES_GCM_JSON_CIPHERTEXT = "ct" +private const val AES_GCM_JSON_IV = "iv" +private const val AES_GCM_KEY_STORE = "AndroidKeyStore" + +internal fun encryptUsingAesGcmWithKeyStore(plaintext: String, namespace: String): JSONObject { + val cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateAesGcmKey(namespace)) + cipher.updateAAD(aesGcmAssociatedData(namespace)) + + return JSONObject() + .put(AES_GCM_JSON_IV, Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .put( + AES_GCM_JSON_CIPHERTEXT, + Base64.encodeToString(cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP) + ) +} + +internal fun decryptUsingAesGcmWithKeyStore(encrypted: JSONObject, namespace: String): String { + val iv = encrypted.requiredBase64(AES_GCM_JSON_IV) + val ciphertext = encrypted.requiredBase64(AES_GCM_JSON_CIPHERTEXT) + if (iv.size != AES_GCM_IV_SIZE_BYTES) { + throw UnrecoverableCiphertextException("Version 5 ciphertext has an invalid IV.") + } + + val key = try { + loadAesGcmKey(namespace) + } catch (e: UnrecoverableKeyException) { + throw LocalEncryptionKeyLostException("The version 5 local encryption key is unavailable.", e) + } ?: throw LocalEncryptionKeyLostException("The version 5 local encryption key is missing.") + + return try { + val cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(AES_GCM_TAG_SIZE_BITS, iv)) + cipher.updateAAD(aesGcmAssociatedData(namespace)) + String(cipher.doFinal(ciphertext), Charsets.UTF_8) + } catch (e: AEADBadTagException) { + throw LocalEncryptionKeyLostException( + "Version 5 ciphertext cannot be authenticated with the local encryption key.", + e + ) + } catch (e: KeyPermanentlyInvalidatedException) { + throw LocalEncryptionKeyLostException("The version 5 local encryption key was invalidated.", e) + } catch (e: BadPaddingException) { + // Some AndroidKeyStore providers surface a GCM authentication failure as BadPaddingException. + throw LocalEncryptionKeyLostException( + "Version 5 ciphertext cannot be authenticated with the local encryption key.", + e + ) + } +} + +internal fun deleteAesGcmEncryptionKeyFromKeyStoreIfExists(namespace: String) { + synchronized(aesGcmKeyLock) { + val keyStore = KeyStore.getInstance(AES_GCM_KEY_STORE) + keyStore.load(null, null) + keyStore.deleteEntry(aesGcmKeyAlias(namespace)) + } +} + +private fun getOrCreateAesGcmKey(namespace: String): SecretKey = synchronized(aesGcmKeyLock) { + loadAesGcmKey(namespace)?.let { return@synchronized it } + + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, AES_GCM_KEY_STORE) + val keySpec = KeyGenParameterSpec.Builder( + aesGcmKeyAlias(namespace), + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(AES_GCM_KEY_SIZE_BITS) + .build() + keyGenerator.init(keySpec) + keyGenerator.generateKey() +} + +private fun loadAesGcmKey(namespace: String): SecretKey? { + val keyStore = KeyStore.getInstance(AES_GCM_KEY_STORE) + keyStore.load(null, null) + return keyStore.getKey(aesGcmKeyAlias(namespace), null) as? SecretKey +} + +private fun JSONObject.requiredBase64(name: String): ByteArray { + val encoded = optString(name, "") + if (encoded.isEmpty()) { + throw UnrecoverableCiphertextException("Version 5 ciphertext is missing $name.") + } + return try { + Base64.decode(encoded, Base64.NO_WRAP) + } catch (e: IllegalArgumentException) { + throw UnrecoverableCiphertextException("Version 5 ciphertext contains invalid $name.", e) + } +} + +private fun aesGcmKeyAlias(namespace: String): String = namespace + AES_GCM_KEY_ALIAS_SUFFIX + +private fun aesGcmAssociatedData(namespace: String): ByteArray = + "securepreferences:$namespace:v5".toByteArray(Charsets.UTF_8) + +private val aesGcmKeyLock = Any() diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt index 4acb831..7df8db4 100644 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt @@ -9,6 +9,16 @@ open class UnrecoverableCiphertextException( cause: Throwable? = null ) : Exception(message, cause) +/** + * Ciphertext that cannot be decrypted because its device-local AndroidKeyStore key is gone, + * invalidated, or no longer authenticates it. The host app should start its credential recovery + * flow instead of retrying the same operation. + */ +class LocalEncryptionKeyLostException( + message: String, + cause: Throwable? = null +) : UnrecoverableCiphertextException(message, cause) + /** * The encrypted preference store could not be opened even after being reset, so nothing can be * encrypted or decrypted through it. Unlike [UnrecoverableCiphertextException] this may clear up, From 2170caee241d709df75f9c96c3e5d5b727b298c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?minhnguyen=E3=83=9F=E3=83=B3?= Date: Tue, 18 Aug 2026 16:55:49 +0900 Subject: [PATCH 3/6] Migrate library build to AGP 9 --- build.gradle | 8 +++----- gradle/wrapper/gradle-wrapper.properties | 3 ++- securepreferences/build.gradle | 19 +++++++------------ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/build.gradle b/build.gradle index 817bdff..e12ea05 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.8.22' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.1.1' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'com.android.tools.build:gradle:9.2.1' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } @@ -22,6 +20,6 @@ allprojects { } } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index da1db5f..7a888fc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/securepreferences/build.gradle b/securepreferences/build.gradle index 6fd239c..9a22b39 100644 --- a/securepreferences/build.gradle +++ b/securepreferences/build.gradle @@ -1,32 +1,28 @@ apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' group = 'com.github.Bitcoin-com' version = '1.2.6' android { - namespace 'com.bitcoin.securepreferences' - compileSdkVersion 34 + namespace = 'com.bitcoin.securepreferences' + compileSdk = 34 defaultConfig { - targetSdkVersion 32 - minSdkVersion 23 - versionCode 10206 - versionName "1.2.6" + minSdk = 23 - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { - minifyEnabled false + minifyEnabled = false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } } @@ -45,7 +41,6 @@ dependencies { // testImplementation group: 'org.hamcrest', name: 'hamcrest', version: '2.2' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "androidx.security:security-crypto:1.1.0" androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' From 9aeba5d9db1bb85ced808d2422d4e5e47905c5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?minhnguyen=E3=83=9F=E3=83=B3?= Date: Tue, 18 Aug 2026 17:03:11 +0900 Subject: [PATCH 4/6] Finish Gradle 9 migration cleanup --- build.gradle | 2 +- gradle.properties | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index e12ea05..92bbf6f 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ allprojects { repositories { google() mavenCentral() - maven { url 'https://jitpack.io' } + maven { url = uri('https://jitpack.io') } } } diff --git a/gradle.properties b/gradle.properties index 23339e0..83df5e3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,5 @@ org.gradle.jvmargs=-Xmx1536m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official From d30bd0a506ed0870e7f5898d3a2c11cade856a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?minhnguyen=E3=83=9F=E3=83=B3?= Date: Fri, 21 Aug 2026 15:03:48 +0900 Subject: [PATCH 5/6] Focus recovery on legacy encrypted preferences --- .../SecurePreferencesTest.kt | 42 +----- .../securepreferences/SecurePreferences.kt | 1 - .../SecureStringEncrypter.kt | 44 +------ .../com/bitcoin/securepreferences/aes_gcm.kt | 120 ------------------ .../bitcoin/securepreferences/exceptions.kt | 4 +- 5 files changed, 8 insertions(+), 203 deletions(-) delete mode 100644 securepreferences/src/main/java/com/bitcoin/securepreferences/aes_gcm.kt diff --git a/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt b/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt index 5876060..f076df4 100644 --- a/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt +++ b/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt @@ -2,7 +2,6 @@ package com.bitcoin.securepreferences import android.content.Context import android.content.SharedPreferences -import android.util.Base64 import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.test.core.app.ApplicationProvider @@ -110,53 +109,18 @@ class SecurePreferencesTest { } @Test - fun defaultEncryptionUsesVersion5AndRoundTrips() { + fun defaultEncryptionRemainsVersion3AndRoundTrips() { val encrypter = SecureStringEncrypter( ApplicationProvider.getApplicationContext(), - "version5-round-trip" + "version3-round-trip" ) val ciphertext = encrypter.encryptString("secret") - assertEquals(SecureStringEncrypter.VERSION_KEY_STORE_AES_GCM, encrypter.getEncryptionType(ciphertext)) + assertEquals(SecureStringEncrypter.VERSION_KEY_STORE_AES, encrypter.getEncryptionType(ciphertext)) assertEquals("secret", encrypter.decryptString(ciphertext)) } - @Test - fun missingVersion5KeyThrowsTypedKeyLoss() { - val namespace = "version5-missing-key" - val encrypter = SecureStringEncrypter(ApplicationProvider.getApplicationContext(), namespace) - val ciphertext = encrypter.encryptString("secret") - deleteAesGcmEncryptionKeyFromKeyStoreIfExists(namespace) - - try { - encrypter.decryptString(ciphertext) - fail("Expected LocalEncryptionKeyLostException") - } catch (_: LocalEncryptionKeyLostException) { - // Expected: callers can route this failure into credential recovery. - } - } - - @Test - fun modifiedVersion5CiphertextThrowsTypedKeyLoss() { - val encrypter = SecureStringEncrypter( - ApplicationProvider.getApplicationContext(), - "version5-modified-ciphertext" - ) - val container = JSONObject(encrypter.encryptString("secret")) - val encrypted = container.getJSONObject("encrypted") - val bytes = Base64.decode(encrypted.getString("ct"), Base64.NO_WRAP) - bytes[0] = (bytes[0].toInt() xor 1).toByte() - encrypted.put("ct", Base64.encodeToString(bytes, Base64.NO_WRAP)) - - try { - encrypter.decryptString(container.toString()) - fail("Expected LocalEncryptionKeyLostException") - } catch (_: LocalEncryptionKeyLostException) { - // Expected: AES-GCM authenticates the ciphertext before returning plaintext. - } - } - @Test fun missingLegacyVersion4DataKeyThrowsTypedKeyLoss() { val encrypter = SecureStringEncrypter( diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt index 21b04a5..2a25256 100644 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt @@ -34,7 +34,6 @@ class SecurePreferences(context: Context, private val namespace: String) { fun clear() { editor.clear() deleteAesEncryptionKeyFromKeyStoreIfExists(namespace) - deleteAesGcmEncryptionKeyFromKeyStoreIfExists(namespace) deleteRsaEncryptionKeyFromKeyStoreIfExists(namespace) } diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt index 54c77dd..d70dfd0 100644 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt @@ -55,7 +55,6 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { VERSION_AES_KEY_ENCRYPTED_PREFERENCE -> encryptStringUsingAesThenEncryptedPreference( value ) - VERSION_KEY_STORE_AES_GCM -> encryptStringUsingKeystoreAesGcm(value) VERSION_AES_KEY_STORE_RSA -> encryptStringUsingAesThenKeystoreRsa(value) else -> encryptString(value) } @@ -71,23 +70,11 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { @Synchronized fun encryptString(value: String): String { - return encryptStringUsingKeystoreAesGcm(value) + return encryptStringUsingKeystoreAes(value) } val encryptedSharedPreference: SharedPreferences by lazy { openEncryptedSharedPreference() } - /** - * True once the encrypted preference store has been reset because its KeyStore key was lost. - * Everything written with [VERSION_AES_KEY_ENCRYPTED_PREFERENCE] before that is unreadable, so - * callers should re-derive it from a backup and then call [acknowledgeEncryptedPreferenceReset]. - */ - fun wasEncryptedPreferenceReset(): Boolean = - stateSharedPreference.getBoolean(KEY_WAS_RESET, false) - - fun acknowledgeEncryptedPreferenceReset() { - stateSharedPreference.edit().remove(KEY_WAS_RESET).commit() - } - /** * Opens the encrypted preference store, recovering when the KeyStore key that wraps its Tink * keyset no longer matches it — the state a device restore or a key invalidation leaves behind. @@ -105,7 +92,7 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { clearEncryptedPreferenceFile() try { - return createEncryptedSharedPreference().also { recordReset() } + return createEncryptedSharedPreference() } catch (e: Exception) { if (!e.isUnrecoverableKeyStoreFailure()) throw e Log.w(TAG, "Encrypted preference keyset still unusable, replacing master key", e) @@ -115,7 +102,7 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { deleteAndroidxMasterKey() clearEncryptedPreferenceFile() return try { - createEncryptedSharedPreference().also { recordReset() } + createEncryptedSharedPreference() } catch (e: Exception) { throw EncryptedPreferenceUnavailableException( "Unable to open the encrypted preference store.", e @@ -153,13 +140,6 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { } } - private val stateSharedPreference: SharedPreferences - get() = mApplicationContext.getSharedPreferences(STATE_PREFERENCE_FILE, Context.MODE_PRIVATE) - - private fun recordReset() { - stateSharedPreference.edit().putBoolean(KEY_WAS_RESET, true).commit() - } - private fun encryptStringUsingAesThenEncryptedPreference(value: String): String { val aesEncrypted: AesEncryptionResult = encryptUsingAesWithoutKeystore(value) val encrypted = JSONObject() @@ -194,13 +174,6 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { return container.toString() } - private fun encryptStringUsingKeystoreAesGcm(value: String): String { - return JSONObject() - .put(JSON_VERSION, VERSION_KEY_STORE_AES_GCM) - .put(JSON_ENCRYPTED, encryptUsingAesGcmWithKeyStore(value, namespace)) - .toString() - } - private fun encryptStringUsingKeystoreAes(value: String): String { val encrypted: JSONObject = encryptUsingAesWithKeystore(value, namespace) //Log.d(TAG, "aesEncrypted: ${encrypted}") @@ -248,14 +221,6 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { ?: throw Exception("Encrypted value for encrypted data version $version not found.") return decryptStringEncryptedUsingAesEncryptedSharedPreference(encrypted) } - VERSION_KEY_STORE_AES_GCM -> { - val encrypted = parsed.optJSONObject(JSON_ENCRYPTED) - ?: throw UnrecoverableCiphertextException( - "Encrypted value for encrypted data version $version not found." - ) - return decryptUsingAesGcmWithKeyStore(encrypted, namespace) - } - else -> throw Exception("Version of encrypted data not recognised.") } } @@ -306,11 +271,8 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { const val VERSION_AES_KEY_STORE_RSA: Int = 2 const val VERSION_KEY_STORE_AES: Int = 3 const val VERSION_AES_KEY_ENCRYPTED_PREFERENCE = 4 - const val VERSION_KEY_STORE_AES_GCM: Int = 5 private const val ENCRYPTED_PREFERENCE_FILE: String = "private_pref" - private const val STATE_PREFERENCE_FILE: String = "securepreferences_state" - private const val KEY_WAS_RESET: String = "encrypted_preference_was_reset" private const val PROVIDER_ANDROID_KEY_STORE: String = "AndroidKeyStore" // androidx.security.crypto.MasterKeys.MASTER_KEY_ALIAS is package private. diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/aes_gcm.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/aes_gcm.kt deleted file mode 100644 index b7571b3..0000000 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/aes_gcm.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.bitcoin.securepreferences - -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyPermanentlyInvalidatedException -import android.security.keystore.KeyProperties -import android.util.Base64 -import org.json.JSONObject -import java.security.KeyStore -import java.security.UnrecoverableKeyException -import javax.crypto.AEADBadTagException -import javax.crypto.BadPaddingException -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec - -private const val AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding" -private const val AES_GCM_KEY_ALIAS_SUFFIX = ".aes_gcm_v5" -private const val AES_GCM_KEY_SIZE_BITS = 256 -private const val AES_GCM_TAG_SIZE_BITS = 128 -private const val AES_GCM_IV_SIZE_BYTES = 12 -private const val AES_GCM_JSON_CIPHERTEXT = "ct" -private const val AES_GCM_JSON_IV = "iv" -private const val AES_GCM_KEY_STORE = "AndroidKeyStore" - -internal fun encryptUsingAesGcmWithKeyStore(plaintext: String, namespace: String): JSONObject { - val cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, getOrCreateAesGcmKey(namespace)) - cipher.updateAAD(aesGcmAssociatedData(namespace)) - - return JSONObject() - .put(AES_GCM_JSON_IV, Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) - .put( - AES_GCM_JSON_CIPHERTEXT, - Base64.encodeToString(cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP) - ) -} - -internal fun decryptUsingAesGcmWithKeyStore(encrypted: JSONObject, namespace: String): String { - val iv = encrypted.requiredBase64(AES_GCM_JSON_IV) - val ciphertext = encrypted.requiredBase64(AES_GCM_JSON_CIPHERTEXT) - if (iv.size != AES_GCM_IV_SIZE_BYTES) { - throw UnrecoverableCiphertextException("Version 5 ciphertext has an invalid IV.") - } - - val key = try { - loadAesGcmKey(namespace) - } catch (e: UnrecoverableKeyException) { - throw LocalEncryptionKeyLostException("The version 5 local encryption key is unavailable.", e) - } ?: throw LocalEncryptionKeyLostException("The version 5 local encryption key is missing.") - - return try { - val cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(AES_GCM_TAG_SIZE_BITS, iv)) - cipher.updateAAD(aesGcmAssociatedData(namespace)) - String(cipher.doFinal(ciphertext), Charsets.UTF_8) - } catch (e: AEADBadTagException) { - throw LocalEncryptionKeyLostException( - "Version 5 ciphertext cannot be authenticated with the local encryption key.", - e - ) - } catch (e: KeyPermanentlyInvalidatedException) { - throw LocalEncryptionKeyLostException("The version 5 local encryption key was invalidated.", e) - } catch (e: BadPaddingException) { - // Some AndroidKeyStore providers surface a GCM authentication failure as BadPaddingException. - throw LocalEncryptionKeyLostException( - "Version 5 ciphertext cannot be authenticated with the local encryption key.", - e - ) - } -} - -internal fun deleteAesGcmEncryptionKeyFromKeyStoreIfExists(namespace: String) { - synchronized(aesGcmKeyLock) { - val keyStore = KeyStore.getInstance(AES_GCM_KEY_STORE) - keyStore.load(null, null) - keyStore.deleteEntry(aesGcmKeyAlias(namespace)) - } -} - -private fun getOrCreateAesGcmKey(namespace: String): SecretKey = synchronized(aesGcmKeyLock) { - loadAesGcmKey(namespace)?.let { return@synchronized it } - - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, AES_GCM_KEY_STORE) - val keySpec = KeyGenParameterSpec.Builder( - aesGcmKeyAlias(namespace), - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(AES_GCM_KEY_SIZE_BITS) - .build() - keyGenerator.init(keySpec) - keyGenerator.generateKey() -} - -private fun loadAesGcmKey(namespace: String): SecretKey? { - val keyStore = KeyStore.getInstance(AES_GCM_KEY_STORE) - keyStore.load(null, null) - return keyStore.getKey(aesGcmKeyAlias(namespace), null) as? SecretKey -} - -private fun JSONObject.requiredBase64(name: String): ByteArray { - val encoded = optString(name, "") - if (encoded.isEmpty()) { - throw UnrecoverableCiphertextException("Version 5 ciphertext is missing $name.") - } - return try { - Base64.decode(encoded, Base64.NO_WRAP) - } catch (e: IllegalArgumentException) { - throw UnrecoverableCiphertextException("Version 5 ciphertext contains invalid $name.", e) - } -} - -private fun aesGcmKeyAlias(namespace: String): String = namespace + AES_GCM_KEY_ALIAS_SUFFIX - -private fun aesGcmAssociatedData(namespace: String): ByteArray = - "securepreferences:$namespace:v5".toByteArray(Charsets.UTF_8) - -private val aesGcmKeyLock = Any() diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt index 7df8db4..6aef61a 100644 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt @@ -11,8 +11,8 @@ open class UnrecoverableCiphertextException( /** * Ciphertext that cannot be decrypted because its device-local AndroidKeyStore key is gone, - * invalidated, or no longer authenticates it. The host app should start its credential recovery - * flow instead of retrying the same operation. + * invalidated, or no longer authenticates it. The host app should invoke its existing recovery + * mechanism instead of retrying the same operation. */ class LocalEncryptionKeyLostException( message: String, From f49f6e68043e5e179c6f4b3b63f96ec123564e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?minhnguyen=E3=83=9F=E3=83=B3?= Date: Fri, 21 Aug 2026 15:28:15 +0900 Subject: [PATCH 6/6] Log encrypted preference recovery failures as errors --- .../com/bitcoin/securepreferences/SecureStringEncrypter.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt index d70dfd0..36b146f 100644 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecureStringEncrypter.kt @@ -87,7 +87,7 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { return createEncryptedSharedPreference() } catch (e: Exception) { if (!e.isUnrecoverableKeyStoreFailure()) throw e - Log.w(TAG, "Encrypted preference keyset cannot be unwrapped, discarding it", e) + Log.e(TAG, "Encrypted preference keyset cannot be unwrapped, discarding it", e) } clearEncryptedPreferenceFile() @@ -95,7 +95,7 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { return createEncryptedSharedPreference() } catch (e: Exception) { if (!e.isUnrecoverableKeyStoreFailure()) throw e - Log.w(TAG, "Encrypted preference keyset still unusable, replacing master key", e) + Log.e(TAG, "Encrypted preference keyset still unusable, replacing master key", e) } // The master key itself is unusable, not just the keyset it wrapped. @@ -136,7 +136,7 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { keyStore.deleteEntry(ANDROIDX_MASTER_KEY_ALIAS) } catch (e: Exception) { // Nothing more we can do; the create retry that follows reports the real failure. - Log.w(TAG, "Unable to delete the androidx master key", e) + Log.e(TAG, "Unable to delete the androidx master key", e) } }