From 1a2a9c58d78d4e3ce349391a8ab4fbddf8c352e6 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:14:59 +0900 Subject: [PATCH] Use Android Keystore for device auth storage 1. Encrypt device auth values with a direct Android Keystore AEAD, migrating legacy Tink and DataStore values on read 2. Recover from keystore failures: drop corrupt values, retry transient faults once, reset only this namespace's keystore values 3. Regenerate device keys only when no stored value exists and return synthetic 599 when request signing fails 4. Add missing getPriceAlertAssetIds override in price alerts test fake --- ...kEncryptedKeyValueStoreInstrumentedTest.kt | 248 ++++++++++++++++-- .../android/data/password/AeadProvider.kt | 56 ++++ .../data/password/EncryptedKeyValueStore.kt | 141 ++++++++++ .../data/password/SecureStringStore.kt | 25 ++ ...ecurityStore.kt => TinkDeviceAuthStore.kt} | 49 +++- .../password/TinkEncryptedKeyValueStore.kt | 55 ++-- .../gemwallet/android/di/InteractsModule.kt | 4 +- .../coordinators/device/GetDeviceIdImpl.kt | 3 +- .../SetPriceAlertsEnabledImplTest.kt | 2 + .../gemapi/http/SecurityInterceptor.kt | 34 ++- .../android/application/SecurityStore.kt | 4 +- 11 files changed, 546 insertions(+), 75 deletions(-) create mode 100644 android/app/src/main/kotlin/com/gemwallet/android/data/password/AeadProvider.kt create mode 100644 android/app/src/main/kotlin/com/gemwallet/android/data/password/EncryptedKeyValueStore.kt rename android/app/src/main/kotlin/com/gemwallet/android/data/password/{TinkSecurityStore.kt => TinkDeviceAuthStore.kt} (55%) 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