Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -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
}
2 changes: 0 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
22 changes: 9 additions & 13 deletions securepreferences/build.gradle
Original file line number Diff line number Diff line change
@@ -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
}

}
Expand All @@ -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'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -106,4 +107,40 @@ class SecurePreferencesTest {
assertEquals(retrieved, "value2")

}
}

@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.
}
}
}
3 changes: 1 addition & 2 deletions securepreferences/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.bitcoin.securepreferences">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">


</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,4 @@ class SecurePreferences(context: Context, private val namespace: String) {
return null
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,17 +73,73 @@ 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,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}

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()
Expand Down Expand Up @@ -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.")
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
}
return false
}
Original file line number Diff line number Diff line change
@@ -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)