From 82d06f56b9c2e6c6208d9ce4f1fb907240617466 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Tue, 23 Jan 2024 09:59:27 -0600 Subject: [PATCH] BIT-1430: Add migration from SecureStorage (#728) --- .../datasource/disk/AuthDiskSourceImpl.kt | 10 +- .../auth/datasource/disk/di/AuthDiskModule.kt | 3 + .../datasource/disk/di/PlatformDiskModule.kt | 27 ++ .../disk/legacy/LegacySecureStorage.kt | 32 ++ .../disk/legacy/LegacySecureStorageImpl.kt | 330 ++++++++++++++++++ .../legacy/LegacySecureStorageMigrator.kt | 16 + .../legacy/LegacySecureStorageMigratorImpl.kt | 35 ++ .../datasource/disk/AuthDiskSourceTest.kt | 14 + .../legacy/LegacySecureStorageMigratorTest.kt | 93 +++++ keystores/xamarin-debug.keystore | Bin 0 -> 2651 bytes 10 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorage.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageMigrator.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageMigratorImpl.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/legacy/LegacySecureStorageMigratorTest.kt create mode 100644 keystores/xamarin-debug.keystore diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index 23c4e967b..e8a58661d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -4,8 +4,8 @@ import android.content.SharedPreferences import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY import com.x8bit.bitwarden.data.platform.datasource.disk.BaseEncryptedDiskSource - import com.x8bit.bitwarden.data.platform.datasource.disk.BaseEncryptedDiskSource.Companion.ENCRYPTED_BASE_KEY +import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import kotlinx.coroutines.flow.Flow @@ -35,12 +35,20 @@ private const val ORGANIZATION_KEYS_KEY = "$BASE_KEY:encOrgKeys" class AuthDiskSourceImpl( encryptedSharedPreferences: SharedPreferences, sharedPreferences: SharedPreferences, + legacySecureStorageMigrator: LegacySecureStorageMigrator, private val json: Json, ) : BaseEncryptedDiskSource( encryptedSharedPreferences = encryptedSharedPreferences, sharedPreferences = sharedPreferences, ), AuthDiskSource { + + init { + // We must migrate if necessary before any of the migrated values would be initialized + // and accessed. + legacySecureStorageMigrator.migrateIfNecessary() + } + private val inMemoryPinProtectedUserKeys = mutableMapOf() private val mutableOrganizationsFlowMap = mutableMapOf?>>() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt index 39c6fc666..35f65b10c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.di.EncryptedPreferences import com.x8bit.bitwarden.data.platform.datasource.di.UnencryptedPreferences +import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,11 +25,13 @@ object AuthDiskModule { fun provideAuthDiskSource( @EncryptedPreferences encryptedSharedPreferences: SharedPreferences, @UnencryptedPreferences sharedPreferences: SharedPreferences, + legacySecureStorageMigrator: LegacySecureStorageMigrator, json: Json, ): AuthDiskSource = AuthDiskSourceImpl( encryptedSharedPreferences = encryptedSharedPreferences, sharedPreferences = sharedPreferences, + legacySecureStorageMigrator = legacySecureStorageMigrator, json = json, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt index ccc7efa44..0b42bf4aa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt @@ -1,6 +1,8 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.di +import android.content.Context import android.content.SharedPreferences +import com.x8bit.bitwarden.data.platform.datasource.di.EncryptedPreferences import com.x8bit.bitwarden.data.platform.datasource.di.UnencryptedPreferences import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl @@ -8,9 +10,14 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSourceImpl +import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorage +import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageImpl +import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator +import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigratorImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json import javax.inject.Singleton @@ -33,6 +40,26 @@ object PlatformDiskModule { json = json, ) + @Provides + @Singleton + fun provideLegacySecureStorage( + @ApplicationContext context: Context, + ): LegacySecureStorage = + LegacySecureStorageImpl( + context = context, + ) + + @Provides + @Singleton + fun provideLegacySecureStorageMigrator( + legacySecureStorage: LegacySecureStorage, + @EncryptedPreferences encryptedSharedPreferences: SharedPreferences, + ): LegacySecureStorageMigrator = + LegacySecureStorageMigratorImpl( + legacySecureStorage = legacySecureStorage, + encryptedSharedPreferences = encryptedSharedPreferences, + ) + @Provides @Singleton fun providePushDiskSource( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorage.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorage.kt new file mode 100644 index 000000000..7ea285ade --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorage.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.legacy + +import androidx.security.crypto.EncryptedSharedPreferences + +/** + * Represents a legacy storage system that exists only to migrate data to + * [EncryptedSharedPreferences]. Because no new data will be stored here, only the ability to + * retrieve and clear data is provided. + */ +interface LegacySecureStorage { + /** + * Returns the data for the given [key], or `null` if no data for that key is present or if + * decryption has failed. + */ + fun get(key: String): String? + + /** + * Returns all of the raw keys stored. In some cases these will be hashed versions of the keys + * passed to [get]. + */ + fun getRawKeys(): Set + + /** + * Removes the data for the given [key]. + */ + fun remove(key: String) + + /** + * Removes all data stored. + */ + fun removeAll() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageImpl.kt new file mode 100644 index 000000000..c51ece02b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageImpl.kt @@ -0,0 +1,330 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.legacy + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.security.KeyPairGeneratorSpec +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.core.content.edit +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.InvalidAlgorithmParameterException +import java.security.InvalidKeyException +import java.security.Key +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.PrivateKey +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.Date +import java.util.Locale +import javax.crypto.AEADBadTagException +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.IllegalBlockSizeException +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.IvParameterSpec +import javax.security.auth.x500.X500Principal + +/** + * Primary implementation of [LegacySecureStorage]. + * + * Apart from removing the ability to store new data (rather than just retrieve it) this is adapted + * with minimal modifications from the [Xamarin.Essentials.SecureStorage source code](https://github.com/xamarin/Essentials/blob/main/Xamarin.Essentials/SecureStorage/SecureStorage.android.cs) + */ +@Suppress( + "NestedBlockDepth", + "TooGenericExceptionCaught", + "MagicNumber", +) +@OmitFromCoverage +class LegacySecureStorageImpl( + private val context: Context, +) : LegacySecureStorage { + private val alias = "${context.packageName}.xamarinessentials" + private val sharedPreferences = context.getSharedPreferences(alias, Context.MODE_PRIVATE) + private val locker = Any() + + private val legacyKeyHashFallback: Boolean = true + + override fun get(key: String): String? { + if (key.isBlank()) return null + return platformGetAsync(key) + } + + override fun getRawKeys(): Set = + sharedPreferences.all.keys + + override fun remove(key: String) { + platformRemove(key) + } + + override fun removeAll() { + platformRemoveAll() + } + + private fun platformGetAsync(key: String): String? { + + var encStr: String? = null + var foundLegacyValue = false + + if (legacyKeyHashFallback) { + if (!sharedPreferences.all.containsKey(key)) { + val md5Key = md5Hash(key) + if (sharedPreferences.all.containsKey(md5Key)) { + encStr = sharedPreferences.getString(md5Key, null) + sharedPreferences.edit { putString(key, encStr) } + foundLegacyValue = true + + try { + sharedPreferences.edit { remove(md5Key) } + } catch (e: Exception) { + // no-op + } + } + } + } + + if (!foundLegacyValue) { + encStr = sharedPreferences.getString(key, null) + } + + var decryptedData: String? = null + if (!encStr.isNullOrBlank()) { + try { + val encData = Base64.decode(encStr, Base64.DEFAULT) + synchronized(locker) { + val ks = AndroidKeyStore( + legacySecureStorage = this, + sharedPreferences = sharedPreferences, + context = context, + keystoreAlias = alias, + alwaysUseAsymmetricKeyStorage = false, + ) + decryptedData = ks.decrypt(encData) + } + } catch (e: AEADBadTagException) { + remove(key) + } + } + + return decryptedData + } + + private fun platformRemove(key: String): Boolean { + sharedPreferences.edit { remove(key) } + checkForAndRemoveLegacyKey(key) + return true + } + + private fun checkForAndRemoveLegacyKey(key: String) { + if (legacyKeyHashFallback) { + val md5Key = md5Hash(key) + if (sharedPreferences.all.containsKey(md5Key)) { + try { + sharedPreferences.edit { remove(md5Key) } + } catch (e: Exception) { + // no-op + } + } + } + } + + private fun platformRemoveAll() { + sharedPreferences.edit { clear() } + } + + private fun md5Hash(input: String): String { + val hash = StringBuilder() + val md5provider = MessageDigest.getInstance("MD5") + val bytes = md5provider.digest(input.toByteArray()) + + for (i in bytes.indices) { + hash.append(String.format("%02x", bytes[i])) + } + + return hash.toString() + } +} + +@Suppress("MagicNumber") +private class AndroidKeyStore( + private val legacySecureStorage: LegacySecureStorage, + private val sharedPreferences: SharedPreferences, + private val context: Context, + private val keystoreAlias: String, + alwaysUseAsymmetricKeyStorage: Boolean, +) { + private val alwaysUseAsymmetricKey: Boolean = alwaysUseAsymmetricKeyStorage + private val alias: String = keystoreAlias + private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore") + private val useSymmetricPreferenceKey: String = "essentials_use_symmetric" + + private val prefsMasterKey = "SecureStorageKey" + private val initializationVectorLen = 12; // Android supports an IV of 12 for AES/GCM + + init { + keyStore.load(null) + } + + private fun getKey(): SecretKey { + val useSymmetric = sharedPreferences.getBoolean( + useSymmetricPreferenceKey, + true, + ) + + return if (useSymmetric && !alwaysUseAsymmetricKey) { + getSymmetricKey() + } else { + val keyPair = getAsymmetricKeyPair() + + val existingKeyStr = sharedPreferences.getString(prefsMasterKey, null) + + if (!existingKeyStr.isNullOrBlank()) { + try { + val wrappedKey = Base64.decode(existingKeyStr, Base64.DEFAULT) + val unwrappedKey = unwrapKey(wrappedKey, keyPair.private) + return unwrappedKey as SecretKey + } catch (ikEx: InvalidKeyException) { + // no-op + } catch (ibsEx: IllegalBlockSizeException) { + // no-op + } catch (paddingEx: BadPaddingException) { + // no-op + } + legacySecureStorage.removeAll() + } + + val keyGenerator = KeyGenerator.getInstance("AES") + val defSymmetricKey = keyGenerator.generateKey() + + val newWrappedKey = wrapKey(defSymmetricKey, keyPair.public) + + sharedPreferences.edit { + putString( + prefsMasterKey, + Base64.encodeToString(newWrappedKey, Base64.DEFAULT), + ) + } + + defSymmetricKey + } + } + + private fun getSymmetricKey(): SecretKey { + sharedPreferences.edit { + putBoolean(useSymmetricPreferenceKey, true) + } + + val existingKey = keyStore.getKey(alias, null) + + return if (existingKey != null) { + existingKey as SecretKey + } else { + val keyGenerator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val builder = KeyGenParameterSpec + .Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(false) + + keyGenerator.init(builder.build()) + + keyGenerator.generateKey() + } + } + + private fun getAsymmetricKeyPair(): KeyPair { + sharedPreferences.edit { + putBoolean(useSymmetricPreferenceKey, false) + } + + val asymmetricAlias = "$alias.asymmetric" + val privateKey = keyStore.getKey(asymmetricAlias, null) as PrivateKey? + val publicKey = keyStore.getCertificate(asymmetricAlias)?.publicKey + + return if (privateKey != null && publicKey != null) { + KeyPair(publicKey, privateKey) + } else { + val originalLocale = Locale.getDefault() + try { + Locale.setDefault(Locale.ENGLISH) + val generator = + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore") + val end = ZonedDateTime.now(ZoneOffset.UTC).plusYears(20) + val startDate = Date() + + @Suppress("DEPRECATION") + val endDate = Date(end.year, end.month.value, end.dayOfMonth) + + @Suppress("DEPRECATION") + val builder = KeyPairGeneratorSpec.Builder(context) + .setAlias(asymmetricAlias) + .setSerialNumber(BigInteger.ONE) + .setSubject(X500Principal("CN=$asymmetricAlias CA Certificate")) + .setStartDate(startDate) + .setEndDate(endDate) + + @Suppress("DEPRECATION") + generator.initialize(builder.build()) + generator.generateKeyPair() + } finally { + Locale.setDefault(originalLocale) + } + } + } + + private fun wrapKey(keyToWrap: Key, withKey: Key): ByteArray { + val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + cipher.init(Cipher.WRAP_MODE, withKey) + return cipher.wrap(keyToWrap) + } + + private fun unwrapKey(wrappedData: ByteArray, withKey: Key): Key { + val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + cipher.init(Cipher.UNWRAP_MODE, withKey) + return cipher.unwrap( + wrappedData, + KeyProperties.KEY_ALGORITHM_AES, + Cipher.SECRET_KEY, + ) as SecretKey + } + + @SuppressLint("GetInstance") + fun decrypt(data: ByteArray): String? { + if (data.size < initializationVectorLen) { + return null + } + + val key = getKey() + + val iv = ByteArray(initializationVectorLen) + System.arraycopy(data, 0, iv, 0, initializationVectorLen) + + val cipher = try { + Cipher.getInstance("AES/GCM/NoPadding") + } catch (e: NoSuchAlgorithmException) { + Cipher.getInstance("AES/ECB/PKCS5Padding") // Fallback for old devices + } + + try { + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + } catch (e: InvalidAlgorithmParameterException) { + cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) + } + + val decryptedData = + cipher.doFinal(data, initializationVectorLen, data.size - initializationVectorLen) + + return String(decryptedData, StandardCharsets.UTF_8) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageMigrator.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageMigrator.kt new file mode 100644 index 000000000..367871b6f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageMigrator.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.legacy + +import androidx.security.crypto.EncryptedSharedPreferences + +/** + * Provides the ability to migrate from a legacy "secure storage" system to + * [EncryptedSharedPreferences]. + */ +interface LegacySecureStorageMigrator { + + /** + * Migrates any data from the legacy "secure storage" system to [EncryptedSharedPreferences]. + * After migration, data will be removed from the legacy system. + */ + fun migrateIfNecessary() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageMigratorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageMigratorImpl.kt new file mode 100644 index 000000000..91062e369 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/legacy/LegacySecureStorageMigratorImpl.kt @@ -0,0 +1,35 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.legacy + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.x8bit.bitwarden.data.platform.datasource.disk.BaseEncryptedDiskSource + +/** + * Primary implementation of [LegacySecureStorageMigrator]. + */ +class LegacySecureStorageMigratorImpl( + private val legacySecureStorage: LegacySecureStorage, + private val encryptedSharedPreferences: SharedPreferences, +) : LegacySecureStorageMigrator { + + override fun migrateIfNecessary() { + // If there are no remaining keys, there is no migration to perform. + val keys = legacySecureStorage.getRawKeys() + if (keys.isEmpty()) return + + // For now we are primarily concerned with keys that have not been hashed before storage, + // which will all start with "bwSecureStorage". Hashing only occurred on devices with + // SDK <23. + val plaintextKeys = keys.filter { + it.startsWith(BaseEncryptedDiskSource.ENCRYPTED_BASE_KEY) + } + + plaintextKeys.forEach { unhashedKey -> + val decryptedValue = legacySecureStorage.get(unhashedKey) + encryptedSharedPreferences.edit { + putString(unhashedKey, decryptedValue) + } + legacySecureStorage.remove(unhashedKey) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index 51fb08e89..383552950 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -11,8 +11,13 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorUserDe import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences +import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs import kotlinx.coroutines.test.runTest import kotlinx.serialization.encodeToString import kotlinx.serialization.json.encodeToJsonElement @@ -25,15 +30,24 @@ import org.junit.jupiter.api.Test class AuthDiskSourceTest { private val fakeEncryptedSharedPreferences = FakeSharedPreferences() private val fakeSharedPreferences = FakeSharedPreferences() + private val legacySecureStorageMigrator = mockk() { + every { migrateIfNecessary() } just runs + } private val json = PlatformNetworkModule.providesJson() private val authDiskSource = AuthDiskSourceImpl( encryptedSharedPreferences = fakeEncryptedSharedPreferences, sharedPreferences = fakeSharedPreferences, + legacySecureStorageMigrator = legacySecureStorageMigrator, json = json, ) + @Test + fun `initialization should kick off a legacy migration if necessary`() { + every { legacySecureStorageMigrator.migrateIfNecessary() } + } + @Test fun `uniqueAppId should generate a new ID and update SharedPreferences if none exists`() { val rememberedUniqueAppIdKey = "bwPreferencesStorage:appId" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/legacy/LegacySecureStorageMigratorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/legacy/LegacySecureStorageMigratorTest.kt new file mode 100644 index 000000000..9fc2110cb --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/legacy/LegacySecureStorageMigratorTest.kt @@ -0,0 +1,93 @@ +package com.x8bit.bitwarden.data.auth.datasource.disk.legacy + +import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences +import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorage +import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigratorImpl +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class LegacySecureStorageMigratorTest { + + private val fakeLegacySecureStorage = FakeLegacySecureStorage() + private val fakeSharedPreferences = FakeSharedPreferences() + private val legacySecureStorageMigrator = LegacySecureStorageMigratorImpl( + legacySecureStorage = fakeLegacySecureStorage, + encryptedSharedPreferences = fakeSharedPreferences, + ) + + @Test + fun `migrateIfNecessary when there are no keys left should do nothing`() { + assertTrue(fakeSharedPreferences.all.keys.isEmpty()) + + legacySecureStorageMigrator.migrateIfNecessary() + + assertTrue(fakeSharedPreferences.all.keys.isEmpty()) + } + + @Test + fun `migrateIfNecessary when the keys do not start with bwSecureStorage should do nothing`() { + assertTrue(fakeSharedPreferences.all.keys.isEmpty()) + + fakeLegacySecureStorage.put( + key = "hashedKey", + value = "value", + ) + legacySecureStorageMigrator.migrateIfNecessary() + + assertTrue(fakeSharedPreferences.all.keys.isEmpty()) + } + + @Suppress("MaxLineLength") + @Test + fun `migrateIfNecessary when the keys start with bwSecureStorage should migrate the data and remove it from the legacy storage`() { + assertTrue(fakeSharedPreferences.all.keys.isEmpty()) + + val userId = "userId" + val key = "bwSecureStorage:userKeyAutoUnlock_$userId" + val value = "mockUserAutoUnlockKey" + fakeLegacySecureStorage.put( + key = key, + value = value, + ) + legacySecureStorageMigrator.migrateIfNecessary() + + assertFalse(fakeSharedPreferences.all.keys.isEmpty()) + assertEquals( + value, + fakeSharedPreferences.getString( + key = key, + defaultValue = null, + ), + ) + assertNull( + fakeLegacySecureStorage.get( + key = key, + ), + ) + } +} + +private class FakeLegacySecureStorage : LegacySecureStorage { + private val dataMap = mutableMapOf() + + override fun get(key: String): String? = + dataMap[key] + + override fun getRawKeys(): Set = + dataMap.keys + + override fun remove(key: String) { + dataMap.remove(key) + } + + override fun removeAll() { + dataMap.clear() + } + + fun put(key: String, value: String) { + dataMap[key] = value + } +} diff --git a/keystores/xamarin-debug.keystore b/keystores/xamarin-debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..058b5505b56429257c1b81644e6fa136b8e4518a GIT binary patch literal 2651 zcma)6XEYp$7M`y6G8qyzqB9b`L<^!N`h+D~Cc)?}EJ22dnnASaWid*Wh!P1IgwZ3) z>b;FD(MFqCo^#$i+4FwA_v4=L-ml!B9}-K80s+BDEG;vPMlAYn^f3dF3Rr-p1wybi zerjHj!7$&=eP#z%CwmHRmm9U(@BdlENK`QAXGQ!6J?(d_jB15;j)K8Q2>!Yx zV)U5l#tQcu#}|oC;^akcP=I3hn;z5bdIegegFUj@%HcXqnIHnjOxQ{bGk72e>v_Lz zTUQM?eF;z(@j!ff0KB9aN6++R3Fog@W(r91v6C!ys6br5cn zBGf9~O#f}(oDF97Y*}4tTmNi9>J3vmU709^Vb%!(@PD@BK4oGE$7$ZnN9g#GrG^l? zIwgerk;?MpXCqFpoxeGo@o*XMW*0diY&G%vRlRm)T{Tf$(p4McT#N+qgYZ@F{w3Lv zT=oIQSHfZDNa?u=^}!gm&Ax!po1;D&Rag#G}DXMTYS4S_A_%w4t)+h1q)64(##QfY+U8XbKjc0Ph)kOPbC-hF-EG4DBx5zCju;1TE#||#g zh+6bH*VCX|?%3AQkU1}y<h2cXBY;(xp(_NAa z+9|GX&qd1~vZ>>TycQ(2z>Uk@V9liR;N7cDI=I4av;3yElKqL0xNVyoa& zDzNAeAX}O&A8mQrCv~qlWEEUPBzv#i1Sc|(NfPR{7h1Mg=AUx6$?_aZbv-;H&1*Na zIUaad@5I;B=P)q+q`I+9*BfK;AYOlXvnsww*hu8`5lef^lM_+-cP$~f_bTI>*k}gr zKuhWV1ORowdGeS^qA#4Hz=8`jTcrltCiU1((u(zmSq~?hAHlJ3S*7VkHMH^Z%^$m&f8KaB8)1ED$=5OUUvkc*1W(H)3;RsQbzRJ*h z>#ZuxCn4@~ku%;y^rY-PI=?~dv#>IUT)_!KfT>QKu;9WwOJ{;?ileMtut8%QJ8k}f zXBXq21+j)@d2L!F71QWgT}QNU`r3n^HP}j@KS5%_aYd<&mi%g=csMF-;m;|Yx#K_> z+d@SV9(A$tWms&z860Zu+V8*lA#9U>H~`#W;&^}2_o#i25W(N#L|uNCJEAs0JwO)o zK>H;ny2D43Fl%`P_fqS$pR*mhESS6>XQ}S)74}mKezsEaDi_vsgMPu5PHPA=&LS$Z z!1yaV(bk+o4q@^4K}G@YCG+789;?~p1%|6*(d5TfhAC9U(b4=pwFrDG6{AMh+A4LC z#{JCtlDua^)p4*M&o=w%>%BKjg@oy(*`Z<<$oP%(7^h}_hw>Y)289o2=zVnd#-tQYuipP#e12wzVvtPUI` z8P!OR7-w|im|kGwzNg~yNMSEQiWp>>^qExq*?dO)E2)KEqP{W^O%#^*jy_RUD>{`mB>#-fCf?z;&`LsH5ML))pZOO%|Gpy6Qmi3_p5 z2`sPFML(F6^CROD*LBUZ5?0{0dhD#&4GyXimo#R{Nl+bW kpFDQ%hOj^XdfW`z_+%LNttfmcR3V)W>1G511EUN61s?6rV*mgE literal 0 HcmV?d00001