From f9492a2f4ff8e52f54f31f1fc9af6b49a094e014 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:29:21 +0900 Subject: [PATCH 1/2] Migrate secure prefs to Tink --- android/app/build.gradle.kts | 3 +- ...kEncryptedKeyValueStoreInstrumentedTest.kt | 130 ++++++++++++++++++ .../password/LegacyEncryptedPreferences.kt | 65 +++++++++ .../data/password/PreferencePasswordStore.kt | 59 -------- .../data/password/SecureStringStore.kt | 19 +++ .../password/TinkEncryptedKeyValueStore.kt | 96 +++++++++++++ .../data/password/TinkGemPreferences.kt | 47 +++++++ .../data/password/TinkPasswordStore.kt | 75 +++++----- .../data/password/TinkSecurityStore.kt | 78 +++++++---- .../com/gemwallet/android/di/GatewayModule.kt | 4 +- .../gemwallet/android/di/InteractsModule.kt | 4 +- .../data/password/TinkPasswordStoreTest.kt | 103 ++++++++++++++ .../config/SecurityGemPreferences.kt | 42 ------ android/skills/testing.md | 1 + 14 files changed, 559 insertions(+), 167 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStoreInstrumentedTest.kt create mode 100644 android/app/src/main/kotlin/com/gemwallet/android/data/password/LegacyEncryptedPreferences.kt delete mode 100644 android/app/src/main/kotlin/com/gemwallet/android/data/password/PreferencePasswordStore.kt create mode 100644 android/app/src/main/kotlin/com/gemwallet/android/data/password/SecureStringStore.kt create mode 100644 android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStore.kt create mode 100644 android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkGemPreferences.kt create mode 100644 android/app/src/test/kotlin/com/gemwallet/android/data/password/TinkPasswordStoreTest.kt delete mode 100644 android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/SecurityGemPreferences.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8889e6d541..6ff34f5c06 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -275,6 +275,7 @@ dependencies { implementation(libs.lifecycle.runtime.ktx) implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.viewmodel.savedstate) + implementation(libs.datastore) implementation(libs.navigation3.runtime) implementation(libs.navigation3.ui) @@ -282,7 +283,7 @@ dependencies { implementation(libs.widgets) implementation(libs.widgets.material3) - // EncryptedPreferences + // Legacy encrypted preferences migration implementation(libs.androidx.security.crypto) // Auth implementation(libs.androidx.biometric) 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 new file mode 100644 index 0000000000..b68d7c8ebd --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStoreInstrumentedTest.kt @@ -0,0 +1,130 @@ +@file:Suppress("DEPRECATION") + +package com.gemwallet.android.data.password + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +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 + +@RunWith(AndroidJUnit4::class) +class TinkEncryptedKeyValueStoreInstrumentedTest { + + private val context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + cleanup() + } + + @After + fun tearDown() { + cleanup() + } + + @Test + fun passwordStore_migratesLegacyEncryptedPreferencesValue() { + val key = "instrumented_wallet_password" + legacyPreferences().edit().putString(key, "legacy-password").commit() + + val passwordStore = TinkPasswordStore(context) + + assertEquals("legacy-password", passwordStore.getPassword(key)) + assertFalse(legacyPreferences().contains(key)) + assertEquals("legacy-password", passwordStore.getPassword(key)) + } + + @Test + fun gemPreferences_migratesLegacyEncryptedPreferencesValue() { + val key = "instrumented_gem_preference" + legacyPreferences().edit().putString(key, "legacy-preference").commit() + + val preferences = TinkGemPreferences(context) + + assertEquals("legacy-preference", preferences.get(key)) + assertFalse(legacyPreferences().contains(key)) + assertEquals("legacy-preference", preferences.get(key)) + } + + @Test + fun encryptedStore_roundTripsAndRejectsMismatchedAssociatedData() { + val sourceKey = "source-key" + val targetKey = "target-key" + val store = TinkEncryptedKeyValueStore.create( + context = context, + config = TEST_STORE_CONFIG, + ) + + store.putString(sourceKey, "secret-value") + + assertTrue(store.contains(sourceKey)) + assertEquals("secret-value", store.getString(sourceKey)) + + 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()) + + try { + store.getString(targetKey) + fail("Expected mismatched associated data to fail decryption") + } catch (_: GeneralSecurityException) { + } + } + + private fun legacyPreferences() = + EncryptedSharedPreferences.create( + context, + LEGACY_PREFERENCES_FILE_NAME, + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + private fun cleanup() { + listOf( + LEGACY_PREFERENCES_FILE_NAME, + PASSWORD_STORE_PREFERENCES_FILE_NAME, + PASSWORD_STORE_KEYSET_PREFERENCES_FILE_NAME, + GEM_PREFERENCES_FILE_NAME, + GEM_PREFERENCES_KEYSET_FILE_NAME, + TEST_PREFERENCES_FILE_NAME, + TEST_KEYSET_PREFERENCES_FILE_NAME, + ).forEach(context::deleteSharedPreferences) + } + + 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) }}" + } + + 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 val TEST_STORE_CONFIG = TinkStoreConfig( + preferencesFileName = TEST_PREFERENCES_FILE_NAME, + namespace = TEST_NAMESPACE, + keysetName = TEST_KEYSET_NAME, + keysetPreferencesFileName = TEST_KEYSET_PREFERENCES_FILE_NAME, + masterKeyAlias = TEST_MASTER_KEY_ALIAS, + ) + } +} diff --git a/android/app/src/main/kotlin/com/gemwallet/android/data/password/LegacyEncryptedPreferences.kt b/android/app/src/main/kotlin/com/gemwallet/android/data/password/LegacyEncryptedPreferences.kt new file mode 100644 index 0000000000..17c18dd677 --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/LegacyEncryptedPreferences.kt @@ -0,0 +1,65 @@ +@file:Suppress("DEPRECATION") + +package com.gemwallet.android.data.password + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import java.io.File + +// Passwords and Gemstone secure preferences historically shared this file; their keys must stay disjoint. +internal const val LEGACY_PREFERENCES_FILE_NAME = "pwd" + +internal class LegacyEncryptedPreferences( + context: Context, + private val preferencesFileName: String, +) : SecureStringStore { + + private val context = context.applicationContext + + @Volatile + private var sharedPreferences: SharedPreferences? = null + + override fun contains(key: String): Boolean = existingPreferences()?.contains(key) == true + + override fun getString(key: String): String? = existingPreferences()?.getString(key, null) + + override fun putString(key: String, value: String) { + if (!preferences().edit().putString(key, value).commit()) { + throw IllegalStateException("Legacy secure value write failed") + } + } + + override fun removeString(key: String): Boolean = existingPreferences()?.edit()?.remove(key)?.commit() != false + + private fun preferences(): SharedPreferences { + sharedPreferences?.let { return it } + return synchronized(this) { + sharedPreferences ?: createPreferences().also { sharedPreferences = it } + } + } + + private fun existingPreferences(): SharedPreferences? { + if (sharedPreferences == null && !preferencesFile.exists()) { + return null + } + return preferences() + } + + private fun createPreferences(): SharedPreferences { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + return EncryptedSharedPreferences.create( + context, + preferencesFileName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + private val preferencesFile: File + get() = File(context.applicationInfo.dataDir, "shared_prefs/$preferencesFileName.xml") +} diff --git a/android/app/src/main/kotlin/com/gemwallet/android/data/password/PreferencePasswordStore.kt b/android/app/src/main/kotlin/com/gemwallet/android/data/password/PreferencePasswordStore.kt deleted file mode 100644 index 02b64bf0cd..0000000000 --- a/android/app/src/main/kotlin/com/gemwallet/android/data/password/PreferencePasswordStore.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.gemwallet.android.data.password - -import android.annotation.SuppressLint -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import com.gemwallet.android.application.PasswordStore -import com.gemwallet.android.math.append0x -import com.gemwallet.android.math.hex -import java.security.SecureRandom - -class PreferencePasswordStore( - private val context: Context, -) : PasswordStore { - val random = SecureRandom() - - @SuppressLint("ApplySharedPref") - override fun createPassword(key: String): String { - val password = ByteArray(32) - random.nextBytes(password) - getStore().edit(commit = true) { - putString(key, password.hex.append0x()) - } - return password.hex.append0x() - } - - override fun removePassword(key: String): Boolean = - getStore().edit().remove(key).commit() - - override fun getPassword(key: String): String { - val password = getStore().getString(key, null) - ?: throw IllegalAccessError("Password doesn't found") - - return password - } - - override fun putPassword(key: String, password: String) { - getStore().edit(commit = true) { - putString(key, password) - } - } - - private fun getStore(): SharedPreferences { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) -// .setUserAuthenticationRequired(true, 1) -// .setRequestStrongBoxBacked(true) - .build() - return EncryptedSharedPreferences.create( - context, - "pwd", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } -} 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 new file mode 100644 index 0000000000..cd9ae9715f --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/SecureStringStore.kt @@ -0,0 +1,19 @@ +package com.gemwallet.android.data.password + +internal interface SecureStringStore { + fun contains(key: String): Boolean + + fun getString(key: String): String? + + fun putString(key: String, value: String) + + fun removeString(key: String): Boolean +} + +internal fun SecureStringStore.getOrMigrate(legacyStore: SecureStringStore, key: String): String? { + val value = getString(key) ?: legacyStore.getString(key)?.also { + putString(key, it) + } ?: return null + legacyStore.removeString(key) + return value +} 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 new file mode 100644 index 0000000000..c070ce807b --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkEncryptedKeyValueStore.kt @@ -0,0 +1,96 @@ +package com.gemwallet.android.data.password + +import android.content.Context +import com.gemwallet.android.math.hex +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.util.Base64 + +internal class TinkEncryptedKeyValueStore( + context: Context, + private val config: TinkStoreConfig, + private val aeadProvider: TinkAeadProvider, +) : SecureStringStore { + + private val sharedPreferences = context.applicationContext.getSharedPreferences( + config.preferencesFileName, + Context.MODE_PRIVATE, + ) + + override fun contains(key: String): Boolean = sharedPreferences.contains(storageKey(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)) + return String(decryptedValue, UTF_8) + } + + override fun putString(key: String, value: String) { + val encryptedValue = aeadProvider.get().encrypt(value.toByteArray(UTF_8), associatedData(key)) + val encodedValue = Base64.getEncoder().encodeToString(encryptedValue) + if (!sharedPreferences.edit().putString(storageKey(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}" + } + + companion object { + fun create(context: Context, config: TinkStoreConfig): TinkEncryptedKeyValueStore { + return TinkEncryptedKeyValueStore( + context = context, + config = config, + aeadProvider = TinkAeadProvider(context = context, config = config), + ) + } + } +} + +internal data class TinkStoreConfig( + val preferencesFileName: String, + val namespace: String, + val keysetName: String, + val keysetPreferencesFileName: String, + val masterKeyAlias: String, +) + +internal class TinkAeadProvider( + context: Context, + private val config: TinkStoreConfig, +) { + + private val context = context.applicationContext + + @Volatile + private var aead: Aead? = null + + fun get(): Aead { + aead?.let { return it } + return synchronized(this) { + aead ?: buildAead().also { aead = it } + } + } + + 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) + } +} diff --git a/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkGemPreferences.kt b/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkGemPreferences.kt new file mode 100644 index 0000000000..9b09b01f8b --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkGemPreferences.kt @@ -0,0 +1,47 @@ +package com.gemwallet.android.data.password + +import android.content.Context +import uniffi.gemstone.GemPreferences + +internal const val GEM_PREFERENCES_FILE_NAME = "gem_secure_preferences" +private const val GEM_PREFERENCES_NAMESPACE = "gateway_secure_preferences" +private const val GEM_PREFERENCES_KEYSET_NAME = "gem_secure_preferences_keyset" +internal const val GEM_PREFERENCES_KEYSET_FILE_NAME = "gem_secure_preferences_keyset_prefs" +private const val GEM_PREFERENCES_MASTER_KEY_ALIAS = "gem_secure_preferences_master_key" + +private val GEM_PREFERENCES_STORE_CONFIG = TinkStoreConfig( + preferencesFileName = GEM_PREFERENCES_FILE_NAME, + namespace = GEM_PREFERENCES_NAMESPACE, + keysetName = GEM_PREFERENCES_KEYSET_NAME, + keysetPreferencesFileName = GEM_PREFERENCES_KEYSET_FILE_NAME, + masterKeyAlias = GEM_PREFERENCES_MASTER_KEY_ALIAS, +) + +class TinkGemPreferences private constructor( + private val encryptedStore: SecureStringStore, + private val legacyStore: SecureStringStore, +) : GemPreferences { + + constructor(context: Context) : this( + encryptedStore = TinkEncryptedKeyValueStore.create( + context = context, + config = GEM_PREFERENCES_STORE_CONFIG, + ), + legacyStore = LegacyEncryptedPreferences( + context = context, + preferencesFileName = LEGACY_PREFERENCES_FILE_NAME, + ), + ) + + override fun get(key: String): String? = encryptedStore.getOrMigrate(legacyStore, key) + + override fun set(key: String, value: String) { + encryptedStore.putString(key, value) + legacyStore.removeString(key) + } + + override fun remove(key: String) { + encryptedStore.removeString(key) + legacyStore.removeString(key) + } +} diff --git a/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkPasswordStore.kt b/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkPasswordStore.kt index 24bb69c48d..286b28ef8c 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkPasswordStore.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/data/password/TinkPasswordStore.kt @@ -2,46 +2,59 @@ package com.gemwallet.android.data.password import android.content.Context import com.gemwallet.android.application.PasswordStore -import com.google.crypto.tink.Aead -import com.google.crypto.tink.RegistryConfiguration -import com.google.crypto.tink.aead.AesGcmKeyManager -import com.google.crypto.tink.integration.android.AndroidKeysetManager -import dagger.hilt.android.qualifiers.ApplicationContext -import java.security.GeneralSecurityException +import com.gemwallet.android.math.append0x +import com.gemwallet.android.math.hex import java.security.SecureRandom - -class TinkPasswordStore( - @ApplicationContext private val context: Context +internal const val PASSWORD_STORE_PREFERENCES_FILE_NAME = "gem_wallet_passwords" +private const val PASSWORD_STORE_NAMESPACE = "wallet_password" +private const val PASSWORD_STORE_KEYSET_NAME = "gem_wallet_password_keyset" +internal const val PASSWORD_STORE_KEYSET_PREFERENCES_FILE_NAME = "gem_wallet_password_keyset_prefs" +private const val PASSWORD_STORE_MASTER_KEY_ALIAS = "gem_wallet_password_master_key" + +private val PASSWORD_STORE_CONFIG = TinkStoreConfig( + preferencesFileName = PASSWORD_STORE_PREFERENCES_FILE_NAME, + namespace = PASSWORD_STORE_NAMESPACE, + keysetName = PASSWORD_STORE_KEYSET_NAME, + keysetPreferencesFileName = PASSWORD_STORE_KEYSET_PREFERENCES_FILE_NAME, + masterKeyAlias = PASSWORD_STORE_MASTER_KEY_ALIAS, +) + +class TinkPasswordStore internal constructor( + private val encryptedStore: SecureStringStore, + private val legacyStore: SecureStringStore, + private val random: SecureRandom, ) : PasswordStore { - val random = SecureRandom() + constructor(context: Context) : this( + encryptedStore = TinkEncryptedKeyValueStore.create( + context = context, + config = PASSWORD_STORE_CONFIG, + ), + legacyStore = LegacyEncryptedPreferences( + context = context, + preferencesFileName = LEGACY_PREFERENCES_FILE_NAME, + ), + random = SecureRandom(), + ) override fun createPassword(key: String): String { - TODO("Not yet implemented") + val password = ByteArray(32) + random.nextBytes(password) + val value = password.hex.append0x() + encryptedStore.putString(key, value) + legacyStore.removeString(key) + return value } - override fun removePassword(key: String): Boolean { - TODO("Not yet implemented") - } + override fun removePassword(key: String): Boolean = + encryptedStore.removeString(key) and legacyStore.removeString(key) - override fun getPassword(key: String): String { - TODO("Not yet implemented") - } + override fun getPassword(key: String): String = + encryptedStore.getOrMigrate(legacyStore, key) ?: throw IllegalStateException("Password not found") override fun putPassword(key: String, password: String) { - TODO("Not yet implemented") + encryptedStore.putString(key, password) + legacyStore.removeString(key) } - - @Throws(GeneralSecurityException::class) - private fun store(): Aead { - val keysetHandle = AndroidKeysetManager.Builder() - .withSharedPref(context, "ngen_gem_keyset", "master_key") - .withKeyTemplate(AesGcmKeyManager.aes256GcmTemplate()) - .withMasterKeyUri("android-keystore://master_key") - .build() - .keysetHandle - return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) - } - -} \ No newline at end of file +} 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/TinkSecurityStore.kt index 006addbf4a..8d2ac51f34 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/TinkSecurityStore.kt @@ -6,49 +6,67 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.gemwallet.android.application.SecurityStore import com.gemwallet.android.math.fromHex -import com.gemwallet.android.math.append0x -import com.gemwallet.android.math.hex -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 dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.nio.charset.StandardCharsets.UTF_8 + +private const val LEGACY_DEVICE_KEYS_DATASTORE_NAME = "device_keys" +private const val DEVICE_KEYSET_NAME = "ngen_gem_keyset" +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 val DEVICE_KEYS_STORE_CONFIG = TinkStoreConfig( + preferencesFileName = DEVICE_KEYS_PREFERENCES_FILE_NAME, + namespace = DEVICE_KEYS_NAMESPACE, + keysetName = DEVICE_KEYSET_NAME, + keysetPreferencesFileName = DEVICE_KEYSET_PREFERENCES_FILE_NAME, + masterKeyAlias = DEVICE_MASTER_KEY_ALIAS, +) class TinkSecurityStore( - @ApplicationContext private val context: Context, + private val context: Context, ) : SecurityStore { - private val Context.dataStore by preferencesDataStore(name = "device_keys") + private val Context.dataStore by preferencesDataStore(name = LEGACY_DEVICE_KEYS_DATASTORE_NAME) + private val aeadProvider = TinkAeadProvider( + context = context, + config = DEVICE_KEYS_STORE_CONFIG, + ) + private val encryptedStore = TinkEncryptedKeyValueStore( + context = context, + config = DEVICE_KEYS_STORE_CONFIG, + aeadProvider = aeadProvider, + ) + + override suspend fun getValue(key: Any): String = withContext(Dispatchers.IO) { + val keyValue = key.toString() + val value = encryptedStore.getString(keyValue) ?: getLegacyValue(keyValue)?.also { + encryptedStore.putString(keyValue, it) + } ?: throw IllegalStateException("Data not found") + removeLegacyValue(keyValue) + value + } - init { - AeadConfig.register(); + override suspend fun putValue(key: Any, value: String) = withContext(Dispatchers.IO) { + val keyValue = key.toString() + encryptedStore.putString(keyValue, value) + removeLegacyValue(keyValue) } - override suspend fun getValue(key: Any): String { - return context.dataStore.data.map { preferences -> preferences[stringPreferencesKey(key.toString())] } + private suspend fun getLegacyValue(key: String): String? { + return context.dataStore.data.map { preferences -> preferences[stringPreferencesKey(key)] } .firstOrNull()?.let { - String(getAead().decrypt(it.fromHex(), null)) - } ?: throw IllegalStateException("Data not found") + String(aeadProvider.get().decrypt(it.fromHex(), null), UTF_8) + } } - override suspend fun putValue(key: Any, value: String) { + private suspend fun removeLegacyValue(key: String) { context.dataStore.edit { preferences -> - val data = getAead().encrypt(value.toByteArray(), null) - preferences[stringPreferencesKey(key.toString())] = data.hex.append0x() + preferences.remove(stringPreferencesKey(key)) } } - - private fun getAead(): Aead { - val keysetHandle = AndroidKeysetManager.Builder() - .withSharedPref(context, "ngen_gem_keyset", "gem_device_master_key") - .withKeyTemplate(AesGcmKeyManager.aes256GcmTemplate()) - .withMasterKeyUri("android-keystore://gem_device_master_key") - .build() - .keysetHandle - return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) - } - } diff --git a/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt b/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt index a6fdfe2455..5fb1829cd9 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/di/GatewayModule.kt @@ -5,8 +5,8 @@ import com.gemwallet.android.Constants import com.gemwallet.android.cases.nodes.GetCurrentNodeCase import com.gemwallet.android.cases.nodes.GetNodesCase import com.gemwallet.android.cases.nodes.SetCurrentNodeCase -import com.gemwallet.android.data.repositories.config.SecurityGemPreferences import com.gemwallet.android.data.repositories.config.SharedGemPreferences +import com.gemwallet.android.data.password.TinkGemPreferences import com.gemwallet.android.data.services.gemapi.NativeProvider import com.gemwallet.android.data.services.gemapi.NativeProviderConfig import com.gemwallet.android.ui.R as UiR @@ -57,7 +57,7 @@ object GatewayModule { preferences = SharedGemPreferences( sharedPreferences = context.getSharedPreferences("gateway_preferences", Context.MODE_PRIVATE) ), - securePreferences = SecurityGemPreferences(context), + securePreferences = TinkGemPreferences(context), apiUrl = Constants.API_URL ) } 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 8cf3caf2ff..2173b5119d 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 @@ -27,7 +27,7 @@ import com.gemwallet.android.blockchain.services.GemSignMessageOperator import com.gemwallet.android.blockchain.services.GemSignTransactionOperator import com.gemwallet.android.cases.device.SyncSubscription import com.gemwallet.android.cases.wallet.ImportWalletService -import com.gemwallet.android.data.password.PreferencePasswordStore +import com.gemwallet.android.data.password.TinkPasswordStore import com.gemwallet.android.data.password.TinkSecurityStore import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.data.repositories.session.SessionRepository @@ -116,7 +116,7 @@ object InteractsModule { @Provides @Singleton fun providePasswordStore(@ApplicationContext context: Context): PasswordStore = - PreferencePasswordStore(context) + TinkPasswordStore(context) @Provides @Singleton diff --git a/android/app/src/test/kotlin/com/gemwallet/android/data/password/TinkPasswordStoreTest.kt b/android/app/src/test/kotlin/com/gemwallet/android/data/password/TinkPasswordStoreTest.kt new file mode 100644 index 0000000000..ce29784f31 --- /dev/null +++ b/android/app/src/test/kotlin/com/gemwallet/android/data/password/TinkPasswordStoreTest.kt @@ -0,0 +1,103 @@ +package com.gemwallet.android.data.password + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.SecureRandom + +private const val TEST_WALLET_KEY = "wallet-1" +private const val LEGACY_PASSWORD = "legacy-password" +private const val ENCRYPTED_PASSWORD = "encrypted-password" +private const val NEW_PASSWORD = "new-password" +private const val GENERATED_PASSWORD = "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + +class TinkPasswordStoreTest { + + private val encryptedStore = InMemorySecureStringStore() + private val legacyStore = InMemorySecureStringStore() + private val passwordStore = TinkPasswordStore( + encryptedStore = encryptedStore, + legacyStore = legacyStore, + random = IncrementingSecureRandom(), + ) + + @Test + fun getPassword_migratesLegacyValue() { + legacyStore.putString(TEST_WALLET_KEY, LEGACY_PASSWORD) + + assertEquals(LEGACY_PASSWORD, passwordStore.getPassword(TEST_WALLET_KEY)) + assertEquals(LEGACY_PASSWORD, encryptedStore.getString(TEST_WALLET_KEY)) + assertEquals(null, legacyStore.getString(TEST_WALLET_KEY)) + } + + @Test + fun getPassword_prefersEncryptedValueAndRemovesLegacyValue() { + encryptedStore.putString(TEST_WALLET_KEY, ENCRYPTED_PASSWORD) + legacyStore.putString(TEST_WALLET_KEY, LEGACY_PASSWORD) + + assertEquals(ENCRYPTED_PASSWORD, passwordStore.getPassword(TEST_WALLET_KEY)) + assertEquals(null, legacyStore.getString(TEST_WALLET_KEY)) + } + + @Test + fun putPassword_writesEncryptedValueAndRemovesLegacyValue() { + legacyStore.putString(TEST_WALLET_KEY, LEGACY_PASSWORD) + + passwordStore.putPassword(TEST_WALLET_KEY, NEW_PASSWORD) + + assertEquals(NEW_PASSWORD, encryptedStore.getString(TEST_WALLET_KEY)) + assertEquals(null, legacyStore.getString(TEST_WALLET_KEY)) + } + + @Test + fun createPassword_writesGeneratedPasswordAndRemovesLegacyValue() { + legacyStore.putString(TEST_WALLET_KEY, LEGACY_PASSWORD) + + val password = passwordStore.createPassword(TEST_WALLET_KEY) + + assertEquals(GENERATED_PASSWORD, password) + assertEquals(password, encryptedStore.getString(TEST_WALLET_KEY)) + assertEquals(null, legacyStore.getString(TEST_WALLET_KEY)) + } + + @Test + fun removePassword_removesBothStores() { + encryptedStore.putString(TEST_WALLET_KEY, ENCRYPTED_PASSWORD) + legacyStore.putString(TEST_WALLET_KEY, LEGACY_PASSWORD) + + assertTrue(passwordStore.removePassword(TEST_WALLET_KEY)) + assertEquals(null, encryptedStore.getString(TEST_WALLET_KEY)) + assertEquals(null, legacyStore.getString(TEST_WALLET_KEY)) + } + + @Test + fun getPassword_missingValueFailsClosed() { + assertThrows(IllegalStateException::class.java) { + passwordStore.getPassword(TEST_WALLET_KEY) + } + } + + private class InMemorySecureStringStore : SecureStringStore { + private val values = mutableMapOf() + + override fun contains(key: String): Boolean = values.containsKey(key) + + override fun getString(key: String): String? = values[key] + + override fun putString(key: String, value: String) { + values[key] = value + } + + override fun removeString(key: String): Boolean { + values.remove(key) + return true + } + } + + private class IncrementingSecureRandom : SecureRandom() { + override fun nextBytes(bytes: ByteArray) { + bytes.indices.forEach { index -> bytes[index] = index.toByte() } + } + } +} diff --git a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/SecurityGemPreferences.kt b/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/SecurityGemPreferences.kt deleted file mode 100644 index b9794b86d2..0000000000 --- a/android/data/repositories/src/main/kotlin/com/gemwallet/android/data/repositories/config/SecurityGemPreferences.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.gemwallet.android.data.repositories.config - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import uniffi.gemstone.GemPreferences - -class SecurityGemPreferences( - private val context: Context, -) : GemPreferences { - - override fun get(key: String): String? { - return getStore().getString(key, null) - } - - override fun set(key: String, value: String) { - getStore().edit(commit = true) { - putString(key, value) - } - } - - override fun remove(key: String) { - getStore().edit(commit = true) { - remove(key) - } - } - - private fun getStore(): SharedPreferences { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - return EncryptedSharedPreferences.create( - context, - "pwd", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } -} \ No newline at end of file diff --git a/android/skills/testing.md b/android/skills/testing.md index 1767f8a367..d4676925e1 100644 --- a/android/skills/testing.md +++ b/android/skills/testing.md @@ -9,6 +9,7 @@ - `./gradlew :app:testGoogleDebugUnitTest` — app module only - `./gradlew ::testDebugUnitTest` — one feature or shared module - Run the narrowest relevant target while iterating, then finish with broader validation +- For local instrumented tests, start the emulator from the repo root first with `just start-emulator`, then run `just android test-integration` or `cd android && ./gradlew connectedGoogleDebugAndroidTest` ## Test Structure From 6a3ce25daeef30802a1d0e1308758c8711f7d5ed Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:14:47 +0900 Subject: [PATCH 2/2] Remove legacy only on migration --- .../android/data/password/SecureStringStore.kt | 12 ++++++++---- .../android/data/password/TinkSecurityStore.kt | 10 +++++++--- .../data/password/TinkPasswordStoreTest.kt | 16 +++++++++++++--- 3 files changed, 28 insertions(+), 10 deletions(-) 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 cd9ae9715f..a99acf841c 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 @@ -11,9 +11,13 @@ internal interface SecureStringStore { } internal fun SecureStringStore.getOrMigrate(legacyStore: SecureStringStore, key: String): String? { - val value = getString(key) ?: legacyStore.getString(key)?.also { - putString(key, it) - } ?: return null + val currentValue = getString(key) + if (currentValue != null) { + return currentValue + } + + val legacyValue = legacyStore.getString(key) ?: return null + putString(key, legacyValue) legacyStore.removeString(key) - return value + return legacyValue } 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/TinkSecurityStore.kt index 8d2ac51f34..75403afe2f 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/TinkSecurityStore.kt @@ -44,9 +44,13 @@ class TinkSecurityStore( override suspend fun getValue(key: Any): String = withContext(Dispatchers.IO) { val keyValue = key.toString() - val value = encryptedStore.getString(keyValue) ?: getLegacyValue(keyValue)?.also { - encryptedStore.putString(keyValue, it) - } ?: throw IllegalStateException("Data not found") + val currentValue = encryptedStore.getString(keyValue) + if (currentValue != null) { + return@withContext currentValue + } + + val value = getLegacyValue(keyValue) ?: throw IllegalStateException("Data not found") + encryptedStore.putString(keyValue, value) removeLegacyValue(keyValue) value } diff --git a/android/app/src/test/kotlin/com/gemwallet/android/data/password/TinkPasswordStoreTest.kt b/android/app/src/test/kotlin/com/gemwallet/android/data/password/TinkPasswordStoreTest.kt index ce29784f31..1223fc57e2 100644 --- a/android/app/src/test/kotlin/com/gemwallet/android/data/password/TinkPasswordStoreTest.kt +++ b/android/app/src/test/kotlin/com/gemwallet/android/data/password/TinkPasswordStoreTest.kt @@ -26,18 +26,24 @@ class TinkPasswordStoreTest { fun getPassword_migratesLegacyValue() { legacyStore.putString(TEST_WALLET_KEY, LEGACY_PASSWORD) - assertEquals(LEGACY_PASSWORD, passwordStore.getPassword(TEST_WALLET_KEY)) + val migratedPassword = passwordStore.getPassword(TEST_WALLET_KEY) + val storedPassword = passwordStore.getPassword(TEST_WALLET_KEY) + + assertEquals(LEGACY_PASSWORD, migratedPassword) + assertEquals(LEGACY_PASSWORD, storedPassword) assertEquals(LEGACY_PASSWORD, encryptedStore.getString(TEST_WALLET_KEY)) assertEquals(null, legacyStore.getString(TEST_WALLET_KEY)) + assertEquals(1, legacyStore.removeCount(TEST_WALLET_KEY)) } @Test - fun getPassword_prefersEncryptedValueAndRemovesLegacyValue() { + fun getPassword_prefersEncryptedValueWithoutRemovingLegacyValue() { encryptedStore.putString(TEST_WALLET_KEY, ENCRYPTED_PASSWORD) legacyStore.putString(TEST_WALLET_KEY, LEGACY_PASSWORD) assertEquals(ENCRYPTED_PASSWORD, passwordStore.getPassword(TEST_WALLET_KEY)) - assertEquals(null, legacyStore.getString(TEST_WALLET_KEY)) + assertEquals(LEGACY_PASSWORD, legacyStore.getString(TEST_WALLET_KEY)) + assertEquals(0, legacyStore.removeCount(TEST_WALLET_KEY)) } @Test @@ -80,6 +86,7 @@ class TinkPasswordStoreTest { private class InMemorySecureStringStore : SecureStringStore { private val values = mutableMapOf() + private val removeCounts = mutableMapOf() override fun contains(key: String): Boolean = values.containsKey(key) @@ -90,9 +97,12 @@ class TinkPasswordStoreTest { } override fun removeString(key: String): Boolean { + removeCounts[key] = removeCount(key) + 1 values.remove(key) return true } + + fun removeCount(key: String): Int = removeCounts[key] ?: 0 } private class IncrementingSecureRandom : SecureRandom() {