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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -275,14 +275,15 @@ 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)

implementation(libs.widgets)
implementation(libs.widgets.material3)

// EncryptedPreferences
// Legacy encrypted preferences migration
implementation(libs.androidx.security.crypto)
// Auth
implementation(libs.androidx.biometric)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Context>()

@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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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")
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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 currentValue = getString(key)
if (currentValue != null) {
return currentValue
}

val legacyValue = legacyStore.getString(key) ?: return null
putString(key, legacyValue)
legacyStore.removeString(key)
Comment thread
0xh3rman marked this conversation as resolved.
return legacyValue
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading