diff --git a/android/app/src/androidTest/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStoreInstrumentedTest.kt b/android/app/src/androidTest/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStoreInstrumentedTest.kt index b68d7c8ebd..228601b51c 100644 --- a/android/app/src/androidTest/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStoreInstrumentedTest.kt +++ b/android/app/src/androidTest/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStoreInstrumentedTest.kt @@ -7,18 +7,23 @@ import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.gemwallet.android.math.fromHex +import com.gemwallet.android.math.hex +import com.google.crypto.tink.integration.android.AndroidKeystore +import com.google.crypto.tink.proto.EncryptedKeyset +import com.google.crypto.tink.shaded.protobuf.ByteString import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue -import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import java.nio.charset.StandardCharsets.UTF_8 import java.security.GeneralSecurityException -import java.security.MessageDigest +import javax.crypto.BadPaddingException @RunWith(AndroidJUnit4::class) class TinkEncryptedKeyValueStoreInstrumentedTest { @@ -60,31 +65,155 @@ class TinkEncryptedKeyValueStoreInstrumentedTest { } @Test - fun encryptedStore_roundTripsAndRejectsMismatchedAssociatedData() { - val sourceKey = "source-key" - val targetKey = "target-key" - val store = TinkEncryptedKeyValueStore.create( - context = context, - config = TEST_STORE_CONFIG, - ) + fun storageKeyDerivationAndValueFormatArePinned() { + assertEquals(PINNED_STORAGE_KEY, storageKey(TEST_NAMESPACE, PINNED_KEY)) + assertEquals(PINNED_VALUE_PREFIX, KEYSTORE_VALUE_PREFIX) + } - store.putString(sourceKey, "secret-value") + @Test + fun encryptedStore_persistsUnderPinnedStorageKeyAndPrefix() { + val store = encryptedStore() - assertTrue(store.contains(sourceKey)) - assertEquals("secret-value", store.getString(sourceKey)) + store.putString(PINNED_KEY, PINNED_VALUE) - val rawPreferences = context.getSharedPreferences(TEST_PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) - val encryptedValue = rawPreferences.getString(storageKey(TEST_NAMESPACE, sourceKey), null) - assertNotNull(encryptedValue) - assertTrue(rawPreferences.edit().putString(storageKey(TEST_NAMESPACE, targetKey), encryptedValue).commit()) + val storedValue = testPreferences().getString(PINNED_STORAGE_KEY, null) + assertNotNull(storedValue) + assertTrue(storedValue!!.startsWith(PINNED_VALUE_PREFIX)) + assertEquals(PINNED_VALUE, store.getString(PINNED_KEY)) + } - try { - store.getString(targetKey) - fail("Expected mismatched associated data to fail decryption") - } catch (_: GeneralSecurityException) { + @Test + fun tinkStore_rejectsMismatchedAssociatedData() { + val store = legacyTinkStore() + store.putString(SOURCE_KEY, SECRET_VALUE) + + copyStoredValue(SOURCE_KEY, TARGET_KEY) + + assertThrows(GeneralSecurityException::class.java) { + store.getString(TARGET_KEY) } } + @Test + fun encryptedStore_removesOnlyValueWithMismatchedAssociatedData() { + val store = encryptedStore() + store.putString(SOURCE_KEY, SECRET_VALUE) + store.putString("other-key", "other-value") + + copyStoredValue(SOURCE_KEY, TARGET_KEY) + + assertNull(store.getString(TARGET_KEY)) + assertFalse(store.contains(TARGET_KEY)) + assertEquals(SECRET_VALUE, store.getString(SOURCE_KEY)) + assertEquals("other-value", store.getString("other-key")) + } + + @Test + fun encryptedStore_removesOnlyCorruptValue() { + val store = encryptedStore() + store.putString("corrupt-key", "corrupt-value") + store.putString("healthy-key", "healthy-value") + + corruptStoredValue("corrupt-key") + + assertNull(store.getString("corrupt-key")) + assertFalse(store.contains("corrupt-key")) + assertEquals("healthy-value", store.getString("healthy-key")) + } + + @Test + fun tinkStore_doesNotResetInvalidKeyset() { + val originalStore = legacyTinkStore() + originalStore.putString(ORIGINAL_KEY, ORIGINAL_VALUE) + mockCorruptKeyset() + + val reopenedStore = legacyTinkStore() + + assertThrows(BadPaddingException::class.java) { + reopenedStore.putString(NEW_KEY, NEW_VALUE) + } + assertTrue(reopenedStore.contains(ORIGINAL_KEY)) + } + + @Test + fun encryptedStore_writesKeystoreCiphertextWithoutCreatingTinkKeyset() { + val store = encryptedStore() + + store.putString(NEW_KEY, NEW_VALUE) + + assertEquals(NEW_VALUE, store.getString(NEW_KEY)) + assertKeystoreValue(NEW_KEY) + assertFalse(keysetPreferences().contains(TEST_KEYSET_NAME)) + } + + @Test + fun encryptedStore_migratesTinkValueOnRead() { + legacyTinkStore().putString(LEGACY_KEY, LEGACY_VALUE) + val store = encryptedStore() + + assertEquals(LEGACY_VALUE, store.getString(LEGACY_KEY)) + assertKeystoreValue(LEGACY_KEY) + + mockCorruptKeyset() + assertEquals(LEGACY_VALUE, encryptedStore().getString(LEGACY_KEY)) + } + + @Test + fun encryptedStore_newWriteDoesNotLoadCorruptTinkKeyset() { + legacyTinkStore().putString(LEGACY_KEY, LEGACY_VALUE) + mockCorruptKeyset() + + val store = encryptedStore() + store.putString(NEW_KEY, NEW_VALUE) + + assertTrue(store.contains(LEGACY_KEY)) + assertEquals(NEW_VALUE, store.getString(NEW_KEY)) + + assertNull(store.getString(LEGACY_KEY)) + assertTrue(store.contains(LEGACY_KEY)) + } + + @Test + fun encryptedStore_resetsValuesWhenDirectKeyIsMissing() { + val store = encryptedStore() + store.putString(ORIGINAL_KEY, ORIGINAL_VALUE) + AndroidKeystore.deleteKey(TEST_AEAD_KEY_ALIAS) + + val reopenedStore = encryptedStore() + + assertNull(reopenedStore.getString(ORIGINAL_KEY)) + assertFalse(reopenedStore.contains(ORIGINAL_KEY)) + + reopenedStore.putString(NEW_KEY, NEW_VALUE) + assertEquals(NEW_VALUE, reopenedStore.getString(NEW_KEY)) + } + + @Test + fun encryptedStore_resetPreservesLegacyTinkValues() { + legacyTinkStore().putString(LEGACY_KEY, LEGACY_VALUE) + val store = encryptedStore() + store.putString("keystore-key", "keystore-value") + AndroidKeystore.deleteKey(TEST_AEAD_KEY_ALIAS) + + val reopenedStore = encryptedStore() + + assertNull(reopenedStore.getString("keystore-key")) + assertEquals(LEGACY_VALUE, reopenedStore.getString(LEGACY_KEY)) + } + + @Test + fun encryptedStore_writeRecoversWhenDirectKeyIsMissing() { + val store = encryptedStore() + store.putString(ORIGINAL_KEY, ORIGINAL_VALUE) + AndroidKeystore.deleteKey(TEST_AEAD_KEY_ALIAS) + + val reopenedStore = encryptedStore() + reopenedStore.putString(NEW_KEY, NEW_VALUE) + + assertEquals(NEW_VALUE, reopenedStore.getString(NEW_KEY)) + assertNull(reopenedStore.getString(ORIGINAL_KEY)) + } + private fun legacyPreferences() = EncryptedSharedPreferences.create( context, @@ -106,19 +235,90 @@ class TinkEncryptedKeyValueStoreInstrumentedTest { TEST_PREFERENCES_FILE_NAME, TEST_KEYSET_PREFERENCES_FILE_NAME, ).forEach(context::deleteSharedPreferences) + listOf(TEST_MASTER_KEY_ALIAS, TEST_AEAD_KEY_ALIAS).forEach { alias -> + runCatching { AndroidKeystore.deleteKey(alias) } + } } - private fun storageKey(namespace: String, key: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest("$namespace\u0000$key".toByteArray(UTF_8)) - return "${namespace}_${digest.joinToString(separator = "") { "%02x".format(it.toInt() and 0xff) }}" + private fun legacyTinkStore(): TinkEncryptedKeyValueStore = TinkEncryptedKeyValueStore.create( + context = context, + config = TEST_STORE_CONFIG, + ) + + private fun encryptedStore(): EncryptedKeyValueStore = EncryptedKeyValueStore( + context = context, + preferencesFileName = TEST_PREFERENCES_FILE_NAME, + namespace = TEST_NAMESPACE, + aeadProvider = AeadProvider(keyAlias = TEST_AEAD_KEY_ALIAS), + legacyStore = legacyTinkStore(), + resetOnInvalidKey = true, + ) + + private fun assertKeystoreValue(key: String) { + val value = testPreferences().getString(storageKey(TEST_NAMESPACE, key), null) + assertTrue(value?.startsWith(KEYSTORE_VALUE_PREFIX) == true) + } + + private fun copyStoredValue(fromKey: String, toKey: String) { + val preferences = testPreferences() + val encryptedValue = preferences.getString(storageKey(TEST_NAMESPACE, fromKey), null)!! + preferences.edit().putString(storageKey(TEST_NAMESPACE, toKey), encryptedValue).commit() } + private fun corruptStoredValue(key: String) { + val preferences = testPreferences() + val storedValue = preferences.getString(storageKey(TEST_NAMESPACE, key), null)!! + val corruptIndex = KEYSTORE_VALUE_PREFIX.length + 5 + val corruptChar = if (storedValue[corruptIndex] == 'A') 'B' else 'A' + val corruptValue = storedValue.substring(0, corruptIndex) + corruptChar + storedValue.substring(corruptIndex + 1) + preferences.edit().putString(storageKey(TEST_NAMESPACE, key), corruptValue).commit() + } + + private fun mockCorruptKeyset() { + val preferences = keysetPreferences() + val serializedKeyset = preferences.getString(TEST_KEYSET_NAME, null)!!.fromHex() + val keyset = EncryptedKeyset.parseFrom(serializedKeyset) + val encryptedKeyset = keyset.encryptedKeyset.toByteArray() + encryptedKeyset[encryptedKeyset.lastIndex] = (encryptedKeyset.last().toInt() xor 1).toByte() + val corruptedKeyset = keyset.toBuilder() + .setEncryptedKeyset(ByteString.copyFrom(encryptedKeyset)) + .build() + .toByteArray() + .hex + preferences.edit().putString(TEST_KEYSET_NAME, corruptedKeyset).commit() + } + + private fun testPreferences() = + context.getSharedPreferences(TEST_PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) + + private fun keysetPreferences() = + context.getSharedPreferences(TEST_KEYSET_PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) + companion object { private const val TEST_PREFERENCES_FILE_NAME = "instrumented_secure_values" private const val TEST_NAMESPACE = "instrumented_secure_namespace" private const val TEST_KEYSET_NAME = "instrumented_secure_values_keyset" private const val TEST_KEYSET_PREFERENCES_FILE_NAME = "instrumented_secure_values_keyset_prefs" private const val TEST_MASTER_KEY_ALIAS = "instrumented_secure_values_master_key" + private const val TEST_AEAD_KEY_ALIAS = "instrumented_secure_values_aead_v1" + + private const val PINNED_KEY = "pinned-key" + private const val PINNED_VALUE = "pinned-value" + private const val PINNED_VALUE_PREFIX = "android-keystore-v1:" + private const val LEGACY_KEY = "legacy-key" + private const val LEGACY_VALUE = "legacy-value" + private const val NEW_KEY = "new-key" + private const val NEW_VALUE = "new-value" + private const val ORIGINAL_KEY = "original-key" + private const val ORIGINAL_VALUE = "original-value" + private const val SOURCE_KEY = "source-key" + private const val TARGET_KEY = "target-key" + private const val SECRET_VALUE = "secret-value" + + // SHA-256("instrumented_secure_namespace" + NUL + "pinned-key") + private const val PINNED_STORAGE_KEY = + "instrumented_secure_namespace_b83b25f1f343059346ac9d719965a0cb4a6a5abbd677321224783b7b80b65270" + private val TEST_STORE_CONFIG = TinkStoreConfig( preferencesFileName = TEST_PREFERENCES_FILE_NAME, namespace = TEST_NAMESPACE, diff --git a/android/app/src/main/kotlin/com/gemwallet/android/data/password/AeadProvider.kt b/android/app/src/main/kotlin/com/gemwallet/android/data/password/AeadProvider.kt new file mode 100644 index 0000000000..739d259156 --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/AeadProvider.kt @@ -0,0 +1,56 @@ +package com.gemwallet.android.data.password + +import com.google.crypto.tink.Aead +import com.google.crypto.tink.integration.android.AndroidKeystore +import java.security.InvalidKeyException + +internal class AeadProvider( + private val keyAlias: String, +) { + + private var aead: Aead? = null + + fun encrypt( + plaintext: ByteArray, + associatedData: ByteArray, + createKeyIfMissing: Boolean, + ): ByteArray = synchronized(this) { + get(createKeyIfMissing).encrypt(plaintext, associatedData) + } + + fun decrypt(ciphertext: ByteArray, associatedData: ByteArray): ByteArray = synchronized(this) { + get(createKeyIfMissing = false).decrypt(ciphertext, associatedData) + } + + fun refresh() { + synchronized(this) { + aead = null + } + } + + fun reset() { + synchronized(this) { + aead = null + synchronized(ANDROID_KEYSTORE_LOCK) { + AndroidKeystore.deleteKey(keyAlias) + } + } + } + + private fun get(createKeyIfMissing: Boolean): Aead = + aead ?: createAead(createKeyIfMissing).also { aead = it } + + private fun createAead(createKeyIfMissing: Boolean): Aead { + synchronized(ANDROID_KEYSTORE_LOCK) { + if (!AndroidKeystore.hasKey(keyAlias)) { + if (!createKeyIfMissing) { + throw InvalidKeyException("Android Keystore key is missing: $keyAlias") + } + AndroidKeystore.generateNewAes256GcmKey(keyAlias) + } + return AndroidKeystore.getAead(keyAlias) + } + } +} + +internal val ANDROID_KEYSTORE_LOCK = Any() diff --git a/android/app/src/main/kotlin/com/gemwallet/android/data/password/EncryptedKeyValueStore.kt b/android/app/src/main/kotlin/com/gemwallet/android/data/password/EncryptedKeyValueStore.kt new file mode 100644 index 0000000000..dbb87e4394 --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/EncryptedKeyValueStore.kt @@ -0,0 +1,141 @@ +package com.gemwallet.android.data.password + +import android.content.Context +import java.nio.charset.StandardCharsets.UTF_8 +import java.util.Base64 + +internal class EncryptedKeyValueStore( + context: Context, + preferencesFileName: String, + private val namespace: String, + private val aeadProvider: AeadProvider, + private val legacyStore: SecureStringStore, + private val resetOnInvalidKey: Boolean, +) : SecureStringStore { + + private val sharedPreferences = context.applicationContext.getSharedPreferences( + preferencesFileName, + Context.MODE_PRIVATE, + ) + + private val namespacePrefix = "${namespace}_" + + override fun contains(key: String): Boolean = sharedPreferences.contains(storageKey(namespace, key)) + + override fun getString(key: String): String? { + val encryptedValue = sharedPreferences.getString(storageKey(namespace, key), null) ?: return null + if (!encryptedValue.startsWith(KEYSTORE_VALUE_PREFIX)) { + val legacyValue = readLegacyValue(key) ?: return null + runCatching { putString(key, legacyValue) } + return legacyValue + } + val encodedValue = encryptedValue.removePrefix(KEYSTORE_VALUE_PREFIX) + return try { + decryptKeystoreValue(key, encodedValue) + } catch (error: Exception) { + recoverStoredValue(key, encodedValue, error) + } + } + + override fun putString(key: String, value: String) { + val encodedValue = try { + encryptValue(key, value, createKeyIfMissing = !hasKeystoreValues()) + } catch (error: Exception) { + recoverWrite(key, value, error) + } + if (!sharedPreferences.edit().putString(storageKey(namespace, key), encodedValue).commit()) { + throw IllegalStateException("Secure value write failed") + } + } + + override fun removeString(key: String): Boolean = + sharedPreferences.edit().remove(storageKey(namespace, key)).commit() + + private fun encryptValue(key: String, value: String, createKeyIfMissing: Boolean): String { + val encryptedValue = aeadProvider.encrypt( + plaintext = value.toByteArray(UTF_8), + associatedData = associatedData(namespace, key), + createKeyIfMissing = createKeyIfMissing, + ) + return KEYSTORE_VALUE_PREFIX + Base64.getEncoder().encodeToString(encryptedValue) + } + + private fun decryptKeystoreValue(key: String, encodedValue: String): String { + val decryptedValue = aeadProvider.decrypt( + ciphertext = Base64.getDecoder().decode(encodedValue), + associatedData = associatedData(namespace, key), + ) + return String(decryptedValue, UTF_8) + } + + private fun readLegacyValue(key: String): String? = try { + legacyStore.getString(key) + } catch (error: Exception) { + if (!resetOnInvalidKey || !(isSecureValueCorruption(error) || isSecureKeyFailure(error))) { + throw error + } + null + } + + private fun recoverStoredValue(key: String, encodedValue: String, error: Exception): String? { + if (!resetOnInvalidKey) { + throw error + } + if (isSecureValueCorruption(error)) { + removeString(key) + return null + } + if (!isSecureKeyFailure(error)) { + throw error + } + // One retry with a rebuilt Aead separates transient keystore faults from an unusable key. + aeadProvider.refresh() + return try { + decryptKeystoreValue(key, encodedValue) + } catch (retryError: Exception) { + when { + isSecureValueCorruption(retryError) -> { + removeString(key) + null + } + isSecureKeyFailure(retryError) -> { + resetKeystoreValues() + null + } + else -> throw retryError + } + } + } + + private fun recoverWrite(key: String, value: String, error: Exception): String { + if (!resetOnInvalidKey || !isSecureKeyFailure(error)) { + throw error + } + aeadProvider.reset() + return encryptValue(key, value, createKeyIfMissing = true) + } + + // Removes only this namespace's keystore-format values: the preferences file is shared + // with the legacy Tink store, whose values are still decryptable with the Tink keyset. + private fun resetKeystoreValues() { + val editor = sharedPreferences.edit() + sharedPreferences.all.forEach { (preferenceKey, value) -> + if (isNamespacedKeystoreValue(preferenceKey, value)) { + editor.remove(preferenceKey) + } + } + if (!editor.commit()) { + throw IllegalStateException("Secure values reset failed") + } + aeadProvider.reset() + } + + private fun hasKeystoreValues(): Boolean = sharedPreferences.all.any { (preferenceKey, value) -> + isNamespacedKeystoreValue(preferenceKey, value) + } + + private fun isNamespacedKeystoreValue(preferenceKey: String, value: Any?): Boolean = + preferenceKey.startsWith(namespacePrefix) && value is String && value.startsWith(KEYSTORE_VALUE_PREFIX) +} + +internal const val KEYSTORE_VALUE_PREFIX = "android-keystore-v1:" diff --git a/android/app/src/main/kotlin/com/gemwallet/android/data/password/SecureStringStore.kt b/android/app/src/main/kotlin/com/gemwallet/android/data/password/SecureStringStore.kt index a99acf841c..f65584ad9d 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/data/password/SecureStringStore.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/SecureStringStore.kt @@ -1,5 +1,14 @@ package com.gemwallet.android.data.password +import com.gemwallet.android.math.hex +import java.nio.charset.StandardCharsets.UTF_8 +import java.security.InvalidKeyException +import java.security.KeyStoreException +import java.security.MessageDigest +import java.security.ProviderException +import java.security.UnrecoverableKeyException +import javax.crypto.BadPaddingException + internal interface SecureStringStore { fun contains(key: String): Boolean @@ -21,3 +30,19 @@ internal fun SecureStringStore.getOrMigrate(legacyStore: SecureStringStore, key: legacyStore.removeString(key) return legacyValue } + +internal fun associatedData(namespace: String, key: String): ByteArray = "$namespace:$key".toByteArray(UTF_8) + +internal fun storageKey(namespace: String, key: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest("$namespace\u0000$key".toByteArray(UTF_8)) + return "${namespace}_${digest.hex}" +} + +internal fun isSecureValueCorruption(error: Throwable): Boolean = + error is BadPaddingException || error is IllegalArgumentException + +internal fun isSecureKeyFailure(error: Throwable): Boolean = + error is InvalidKeyException || + error is UnrecoverableKeyException || + error is KeyStoreException || + error is ProviderException diff --git a/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkSecurityStore.kt b/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkDeviceAuthStore.kt similarity index 55% rename from android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkSecurityStore.kt rename to android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkDeviceAuthStore.kt index 75403afe2f..4b560951af 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkSecurityStore.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkDeviceAuthStore.kt @@ -1,9 +1,11 @@ package com.gemwallet.android.data.password import android.content.Context +import android.util.Log import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.gemwallet.android.application.SecureValueNotFoundException import com.gemwallet.android.application.SecurityStore import com.gemwallet.android.math.fromHex import kotlinx.coroutines.Dispatchers @@ -18,8 +20,10 @@ private const val DEVICE_KEYSET_PREFERENCES_FILE_NAME = "gem_device_master_key" private const val DEVICE_MASTER_KEY_ALIAS = "gem_device_master_key" private const val DEVICE_KEYS_PREFERENCES_FILE_NAME = "gem_device_keys" private const val DEVICE_KEYS_NAMESPACE = "device_keys" +private const val DEVICE_AEAD_KEY_ALIAS = "gem_device_keys_aead_v1" +private const val TAG = "TinkDeviceAuthStore" -private val DEVICE_KEYS_STORE_CONFIG = TinkStoreConfig( +private val TINK_DEVICE_KEYS_STORE_CONFIG = TinkStoreConfig( preferencesFileName = DEVICE_KEYS_PREFERENCES_FILE_NAME, namespace = DEVICE_KEYS_NAMESPACE, keysetName = DEVICE_KEYSET_NAME, @@ -27,19 +31,27 @@ private val DEVICE_KEYS_STORE_CONFIG = TinkStoreConfig( masterKeyAlias = DEVICE_MASTER_KEY_ALIAS, ) -class TinkSecurityStore( +class TinkDeviceAuthStore( private val context: Context, ) : SecurityStore { private val Context.dataStore by preferencesDataStore(name = LEGACY_DEVICE_KEYS_DATASTORE_NAME) - private val aeadProvider = TinkAeadProvider( + private val tinkAeadProvider = TinkAeadProvider( context = context, - config = DEVICE_KEYS_STORE_CONFIG, + config = TINK_DEVICE_KEYS_STORE_CONFIG, ) - private val encryptedStore = TinkEncryptedKeyValueStore( + private val tinkEncryptedStore = TinkEncryptedKeyValueStore( context = context, - config = DEVICE_KEYS_STORE_CONFIG, - aeadProvider = aeadProvider, + config = TINK_DEVICE_KEYS_STORE_CONFIG, + aeadProvider = tinkAeadProvider, + ) + private val encryptedStore = EncryptedKeyValueStore( + context = context, + preferencesFileName = DEVICE_KEYS_PREFERENCES_FILE_NAME, + namespace = DEVICE_KEYS_NAMESPACE, + aeadProvider = AeadProvider(keyAlias = DEVICE_AEAD_KEY_ALIAS), + legacyStore = tinkEncryptedStore, + resetOnInvalidKey = true, ) override suspend fun getValue(key: Any): String = withContext(Dispatchers.IO) { @@ -49,9 +61,13 @@ class TinkSecurityStore( return@withContext currentValue } - val value = getLegacyValue(keyValue) ?: throw IllegalStateException("Data not found") - encryptedStore.putString(keyValue, value) - removeLegacyValue(keyValue) + val value = getLegacyValue(keyValue) ?: throw SecureValueNotFoundException() + runCatching { + encryptedStore.putString(keyValue, value) + removeLegacyValue(keyValue) + }.onFailure { error -> + Log.e(TAG, "Keeping legacy device auth value, migration failed", error) + } value } @@ -62,10 +78,17 @@ class TinkSecurityStore( } private suspend fun getLegacyValue(key: String): String? { - return context.dataStore.data.map { preferences -> preferences[stringPreferencesKey(key)] } - .firstOrNull()?.let { - String(aeadProvider.get().decrypt(it.fromHex(), null), UTF_8) + val storedValue = context.dataStore.data.map { preferences -> preferences[stringPreferencesKey(key)] } + .firstOrNull() ?: return null + return try { + String(tinkAeadProvider.get().decrypt(storedValue.fromHex(), null), UTF_8) + } catch (error: Exception) { + if (!(isSecureValueCorruption(error) || isSecureKeyFailure(error))) { + throw error } + Log.e(TAG, "Ignoring undecryptable legacy device auth value: ${error.javaClass.simpleName}", error) + null + } } private suspend fun removeLegacyValue(key: String) { diff --git a/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStore.kt b/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStore.kt index c070ce807b..3baeb4ec05 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStore.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStore.kt @@ -1,14 +1,14 @@ package com.gemwallet.android.data.password import android.content.Context -import com.gemwallet.android.math.hex +import android.util.Log import com.google.crypto.tink.Aead import com.google.crypto.tink.RegistryConfiguration import com.google.crypto.tink.aead.AeadConfig import com.google.crypto.tink.aead.AesGcmKeyManager import com.google.crypto.tink.integration.android.AndroidKeysetManager import java.nio.charset.StandardCharsets.UTF_8 -import java.security.MessageDigest +import java.security.GeneralSecurityException import java.util.Base64 internal class TinkEncryptedKeyValueStore( @@ -22,30 +22,30 @@ internal class TinkEncryptedKeyValueStore( Context.MODE_PRIVATE, ) - override fun contains(key: String): Boolean = sharedPreferences.contains(storageKey(key)) + override fun contains(key: String): Boolean = sharedPreferences.contains(storageKey(config.namespace, key)) override fun getString(key: String): String? { - val encryptedValue = sharedPreferences.getString(storageKey(key), null) ?: return null - val decryptedValue = aeadProvider.get().decrypt(Base64.getDecoder().decode(encryptedValue), associatedData(key)) + val encryptedValue = sharedPreferences.getString(storageKey(config.namespace, key), null) ?: return null + val decryptedValue = aeadProvider.get().decrypt( + Base64.getDecoder().decode(encryptedValue), + associatedData(config.namespace, key), + ) return String(decryptedValue, UTF_8) } override fun putString(key: String, value: String) { - val encryptedValue = aeadProvider.get().encrypt(value.toByteArray(UTF_8), associatedData(key)) + val encryptedValue = aeadProvider.get().encrypt( + value.toByteArray(UTF_8), + associatedData(config.namespace, key), + ) val encodedValue = Base64.getEncoder().encodeToString(encryptedValue) - if (!sharedPreferences.edit().putString(storageKey(key), encodedValue).commit()) { + if (!sharedPreferences.edit().putString(storageKey(config.namespace, key), encodedValue).commit()) { throw IllegalStateException("Secure value write failed") } } - override fun removeString(key: String): Boolean = sharedPreferences.edit().remove(storageKey(key)).commit() - - private fun associatedData(key: String): ByteArray = "${config.namespace}:$key".toByteArray(UTF_8) - - private fun storageKey(key: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest("${config.namespace}\u0000$key".toByteArray(UTF_8)) - return "${config.namespace}_${digest.hex}" - } + override fun removeString(key: String): Boolean = + sharedPreferences.edit().remove(storageKey(config.namespace, key)).commit() companion object { fun create(context: Context, config: TinkStoreConfig): TinkEncryptedKeyValueStore { @@ -85,12 +85,23 @@ internal class TinkAeadProvider( private fun buildAead(): Aead { AeadConfig.register() - val keysetHandle = AndroidKeysetManager.Builder() - .withSharedPref(context, config.keysetName, config.keysetPreferencesFileName) - .withKeyTemplate(AesGcmKeyManager.aes256GcmTemplate()) - .withMasterKeyUri("android-keystore://${config.masterKeyAlias}") - .build() - .keysetHandle - return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + synchronized(ANDROID_KEYSTORE_LOCK) { + return try { + keysetAead() + } catch (error: GeneralSecurityException) { + Log.w(TAG, "Retrying Tink keyset load: ${error.javaClass.simpleName}", error) + keysetAead() + } + } } + + private fun keysetAead(): Aead = AndroidKeysetManager.Builder() + .withSharedPref(context, config.keysetName, config.keysetPreferencesFileName) + .withKeyTemplate(AesGcmKeyManager.aes256GcmTemplate()) + .withMasterKeyUri("android-keystore://${config.masterKeyAlias}") + .build() + .keysetHandle + .getPrimitive(RegistryConfiguration.get(), Aead::class.java) } + +private const val TAG = "TinkAeadProvider" diff --git a/android/app/src/main/kotlin/com/gemwallet/android/di/InteractsModule.kt b/android/app/src/main/kotlin/com/gemwallet/android/di/InteractsModule.kt index 7fcb0d2588..18b448ca5f 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/di/InteractsModule.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/di/InteractsModule.kt @@ -28,7 +28,7 @@ import com.gemwallet.android.blockchain.services.GemSignTransactionOperator import com.gemwallet.android.cases.device.SyncDevice import com.gemwallet.android.cases.wallet.ImportWalletService import com.gemwallet.android.data.password.TinkPasswordStore -import com.gemwallet.android.data.password.TinkSecurityStore +import com.gemwallet.android.data.password.TinkDeviceAuthStore import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.data.repositories.wallets.PhraseAddressImportWalletService @@ -121,7 +121,7 @@ object InteractsModule { @Provides @Singleton fun provideSecurityStore(@ApplicationContext context: Context): SecurityStore = - TinkSecurityStore(context) + TinkDeviceAuthStore(context) @Singleton @Provides diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/device/GetDeviceIdImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/device/GetDeviceIdImpl.kt index 11debca58b..969448d277 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/device/GetDeviceIdImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/device/GetDeviceIdImpl.kt @@ -1,5 +1,6 @@ package com.gemwallet.android.data.coordinators.device +import com.gemwallet.android.application.SecureValueNotFoundException import com.gemwallet.android.application.SecurityStore import com.gemwallet.android.application.device.coordinators.GetDeviceId import com.gemwallet.android.math.hex @@ -31,7 +32,7 @@ class GetDeviceIdImpl( privateKey = store.getValue(Keys.PrivateKey), publicKey = store.getValue(Keys.PublicKey), ) - } catch (_: Throwable) {} + } catch (_: SecureValueNotFoundException) {} val deviceKey = generateDeviceKeyPair() val privateKey = deviceKey.privateKey.hex diff --git a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/pricealerts/SetPriceAlertsEnabledImplTest.kt b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/pricealerts/SetPriceAlertsEnabledImplTest.kt index 11c555adeb..eb9219ca89 100644 --- a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/pricealerts/SetPriceAlertsEnabledImplTest.kt +++ b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/pricealerts/SetPriceAlertsEnabledImplTest.kt @@ -81,6 +81,8 @@ class SetPriceAlertsEnabledImplTest { override fun getPriceAlerts(assetId: AssetId?): Flow> = flowOf(emptyList()) + override fun getPriceAlertAssetIds(): Flow> = flowOf(emptyList()) + override fun getAssetPriceAlert(assetId: AssetId): Flow = flowOf(null) override suspend fun addPriceAlert(priceAlert: PriceAlert) = Unit diff --git a/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/http/SecurityInterceptor.kt b/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/http/SecurityInterceptor.kt index 68d9bca95b..de6f4b6fe5 100644 --- a/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/http/SecurityInterceptor.kt +++ b/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/http/SecurityInterceptor.kt @@ -4,9 +4,12 @@ import com.gemwallet.android.application.device.coordinators.GetDeviceId import com.wallet.core.primitives.WalletId import okhttp3.Interceptor import okhttp3.Protocol +import okhttp3.Request import okhttp3.Response import okio.Buffer +const val DEVICE_AUTH_ERROR_CODE = 599 + class SecurityInterceptor internal constructor( private val signer: DeviceRequestSigner, ) : Interceptor { @@ -20,23 +23,30 @@ class SecurityInterceptor internal constructor( it.writeTo(buffer) buffer.readByteArray() } - val signature = signer.sign( - method = request.method, - path = request.url.encodedPath, - body = body, - walletId = request.tag(WalletId::class.java)?.id.orEmpty(), - ) + val signature = try { + signer.sign( + method = request.method, + path = request.url.encodedPath, + body = body, + walletId = request.tag(WalletId::class.java)?.id.orEmpty(), + ) + } catch (error: Throwable) { + return request.errorResponse(DEVICE_AUTH_ERROR_CODE, "Device auth error: ${error.javaClass.simpleName}") + } return try { val builder = request.newBuilder() signature.toHeaders().forEach { (key, value) -> builder.header(key, value) } chain.proceed(builder.build()) } catch (error: Throwable) { - Response.Builder() - .code(503) - .message("HTTP Exception: ${error.message}") - .request(request) - .protocol(Protocol.HTTP_2) - .build() + request.errorResponse(503, "HTTP Exception: ${error.message}") } } } + +internal fun Request.errorResponse(code: Int, message: String): Response = + Response.Builder() + .code(code) + .message(message) + .request(this) + .protocol(Protocol.HTTP_2) + .build() diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/SecurityStore.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/SecurityStore.kt index e8e1ba00c8..4a70b31a63 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/SecurityStore.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/SecurityStore.kt @@ -4,4 +4,6 @@ interface SecurityStore { suspend fun getValue(key: T): String suspend fun putValue(key: T, value: String) -} \ No newline at end of file +} + +class SecureValueNotFoundException : IllegalStateException("Secure value not found") \ No newline at end of file