BIT-1430: Add migration from SecureStorage (#728)

This commit is contained in:
Brian Yencho 2024-01-23 09:59:27 -06:00 committed by Álison Fernandes
parent 17eb3e2e0b
commit 82d06f56b9
10 changed files with 559 additions and 1 deletions

View file

@ -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<String, String?>()
private val mutableOrganizationsFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()

View file

@ -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,
)
}

View file

@ -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(

View file

@ -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<String>
/**
* Removes the data for the given [key].
*/
fun remove(key: String)
/**
* Removes all data stored.
*/
fun removeAll()
}

View file

@ -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<String> =
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)
}
}

View file

@ -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()
}

View file

@ -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)
}
}
}

View file

@ -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<LegacySecureStorageMigrator>() {
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"

View file

@ -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<String, String>()
override fun get(key: String): String? =
dataMap[key]
override fun getRawKeys(): Set<String> =
dataMap.keys
override fun remove(key: String) {
dataMap.remove(key)
}
override fun removeAll() {
dataMap.clear()
}
fun put(key: String, value: String) {
dataMap[key] = value
}
}

Binary file not shown.