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 000000000..058b5505b Binary files /dev/null and b/keystores/xamarin-debug.keystore differ