diff --git a/build.gradle b/build.gradle index 5b0e1ef..92bbf6f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,16 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.7.10' 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 "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 } @@ -19,12 +15,11 @@ buildscript { allprojects { repositories { google() - jcenter() mavenCentral() - maven { url 'https://jitpack.io' } + maven { url = uri('https://jitpack.io') } } } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory } 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ffed3a2..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-7.2-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 bd817e1..9a22b39 100644 --- a/securepreferences/build.gradle +++ b/securepreferences/build.gradle @@ -1,31 +1,28 @@ 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 + namespace = 'com.bitcoin.securepreferences' + compileSdk = 34 defaultConfig { - targetSdkVersion 32 - minSdkVersion 23 - versionCode 10204 - versionName "1.2.4" + 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 } } @@ -44,8 +41,7 @@ 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.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..f076df4 100644 --- a/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt +++ b/securepreferences/src/androidTest/java/com/bitcoin/securepreferences/SecurePreferencesTest.kt @@ -12,6 +12,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 +107,40 @@ class SecurePreferencesTest { assertEquals(retrieved, "value2") } -} \ No newline at end of file + + @Test + fun defaultEncryptionRemainsVersion3AndRoundTrips() { + val encrypter = SecureStringEncrypter( + ApplicationProvider.getApplicationContext(), + "version3-round-trip" + ) + + val ciphertext = encrypter.encryptString("secret") + + assertEquals(SecureStringEncrypter.VERSION_KEY_STORE_AES, encrypter.getEncryptionType(ciphertext)) + assertEquals("secret", encrypter.decryptString(ciphertext)) + } + + @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..2a25256 100644 --- a/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/SecurePreferences.kt @@ -89,4 +89,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 d01ae0f..36b146f 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,47 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { return encryptStringUsingKeystoreAes(value) } - val encryptedSharedPreference: SharedPreferences by lazy { + val encryptedSharedPreference: SharedPreferences by lazy { openEncryptedSharedPreference() } + + /** + * 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.e(TAG, "Encrypted preference keyset cannot be unwrapped, discarding it", e) + } + + clearEncryptedPreferenceFile() + try { + return createEncryptedSharedPreference() + } catch (e: Exception) { + if (!e.isUnrecoverableKeyStoreFailure()) throw 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. + deleteAndroidxMasterKey() + clearEncryptedPreferenceFile() + return try { + createEncryptedSharedPreference() + } 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 +121,25 @@ 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.e(TAG, "Unable to delete the androidx master key", e) + } + } + private fun encryptStringUsingAesThenEncryptedPreference(value: String): String { val aesEncrypted: AesEncryptionResult = encryptUsingAesWithoutKeystore(value) val encrypted = JSONObject() @@ -161,7 +221,6 @@ class SecureStringEncrypter(context: Context, private val namespace: String) { ?: throw Exception("Encrypted value for encrypted data version $version not found.") return decryptStringEncryptedUsingAesEncryptedSharedPreference(encrypted) } - else -> throw Exception("Version of encrypted data not recognised.") } } @@ -176,7 +235,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 LocalEncryptionKeyLostException( + "Data key $keyRef is no longer in the encrypted preference store." + ) } val aesKey = Base64.decode(base64Key) @@ -209,5 +271,37 @@ 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 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..6aef61a --- /dev/null +++ b/securepreferences/src/main/java/com/bitcoin/securepreferences/exceptions.kt @@ -0,0 +1,30 @@ +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) + +/** + * Ciphertext that cannot be decrypted because its device-local AndroidKeyStore key is gone, + * 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, + 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, + * for example once a device with a misbehaving KeyStore is rebooted. + */ +class EncryptedPreferenceUnavailableException( + message: String, + cause: Throwable? = null +) : Exception(message, cause)