mirror of
https://github.com/bitwarden/android.git
synced 2024-11-28 22:08:49 +03:00
BIT-1430: Add migration from SecureStorage (#728)
This commit is contained in:
parent
17eb3e2e0b
commit
82d06f56b9
10 changed files with 559 additions and 1 deletions
|
@ -4,8 +4,8 @@ import android.content.SharedPreferences
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
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.BaseDiskSource.Companion.BASE_KEY
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.disk.BaseEncryptedDiskSource
|
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.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.platform.repository.util.bufferedMutableSharedFlow
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
|
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
@ -35,12 +35,20 @@ private const val ORGANIZATION_KEYS_KEY = "$BASE_KEY:encOrgKeys"
|
||||||
class AuthDiskSourceImpl(
|
class AuthDiskSourceImpl(
|
||||||
encryptedSharedPreferences: SharedPreferences,
|
encryptedSharedPreferences: SharedPreferences,
|
||||||
sharedPreferences: SharedPreferences,
|
sharedPreferences: SharedPreferences,
|
||||||
|
legacySecureStorageMigrator: LegacySecureStorageMigrator,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
) : BaseEncryptedDiskSource(
|
) : BaseEncryptedDiskSource(
|
||||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||||
sharedPreferences = sharedPreferences,
|
sharedPreferences = sharedPreferences,
|
||||||
),
|
),
|
||||||
AuthDiskSource {
|
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 inMemoryPinProtectedUserKeys = mutableMapOf<String, String?>()
|
||||||
private val mutableOrganizationsFlowMap =
|
private val mutableOrganizationsFlowMap =
|
||||||
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
|
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
|
||||||
|
|
|
@ -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.auth.datasource.disk.AuthDiskSourceImpl
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.di.EncryptedPreferences
|
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.di.UnencryptedPreferences
|
||||||
|
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
@ -24,11 +25,13 @@ object AuthDiskModule {
|
||||||
fun provideAuthDiskSource(
|
fun provideAuthDiskSource(
|
||||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||||
|
legacySecureStorageMigrator: LegacySecureStorageMigrator,
|
||||||
json: Json,
|
json: Json,
|
||||||
): AuthDiskSource =
|
): AuthDiskSource =
|
||||||
AuthDiskSourceImpl(
|
AuthDiskSourceImpl(
|
||||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||||
sharedPreferences = sharedPreferences,
|
sharedPreferences = sharedPreferences,
|
||||||
|
legacySecureStorageMigrator = legacySecureStorageMigrator,
|
||||||
json = json,
|
json = json,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package com.x8bit.bitwarden.data.platform.datasource.disk.di
|
package com.x8bit.bitwarden.data.platform.datasource.disk.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
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.di.UnencryptedPreferences
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
|
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl
|
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.PushDiskSourceImpl
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
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.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.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
@ -33,6 +40,26 @@ object PlatformDiskModule {
|
||||||
json = json,
|
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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun providePushDiskSource(
|
fun providePushDiskSource(
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.TrustedDeviceUserDecryptionOptionsJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
|
||||||
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
|
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.platform.datasource.network.di.PlatformNetworkModule
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization
|
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.coroutines.test.runTest
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
@ -25,15 +30,24 @@ import org.junit.jupiter.api.Test
|
||||||
class AuthDiskSourceTest {
|
class AuthDiskSourceTest {
|
||||||
private val fakeEncryptedSharedPreferences = FakeSharedPreferences()
|
private val fakeEncryptedSharedPreferences = FakeSharedPreferences()
|
||||||
private val fakeSharedPreferences = FakeSharedPreferences()
|
private val fakeSharedPreferences = FakeSharedPreferences()
|
||||||
|
private val legacySecureStorageMigrator = mockk<LegacySecureStorageMigrator>() {
|
||||||
|
every { migrateIfNecessary() } just runs
|
||||||
|
}
|
||||||
|
|
||||||
private val json = PlatformNetworkModule.providesJson()
|
private val json = PlatformNetworkModule.providesJson()
|
||||||
|
|
||||||
private val authDiskSource = AuthDiskSourceImpl(
|
private val authDiskSource = AuthDiskSourceImpl(
|
||||||
encryptedSharedPreferences = fakeEncryptedSharedPreferences,
|
encryptedSharedPreferences = fakeEncryptedSharedPreferences,
|
||||||
sharedPreferences = fakeSharedPreferences,
|
sharedPreferences = fakeSharedPreferences,
|
||||||
|
legacySecureStorageMigrator = legacySecureStorageMigrator,
|
||||||
json = json,
|
json = json,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initialization should kick off a legacy migration if necessary`() {
|
||||||
|
every { legacySecureStorageMigrator.migrateIfNecessary() }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `uniqueAppId should generate a new ID and update SharedPreferences if none exists`() {
|
fun `uniqueAppId should generate a new ID and update SharedPreferences if none exists`() {
|
||||||
val rememberedUniqueAppIdKey = "bwPreferencesStorage:appId"
|
val rememberedUniqueAppIdKey = "bwPreferencesStorage:appId"
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
BIN
keystores/xamarin-debug.keystore
Normal file
BIN
keystores/xamarin-debug.keystore
Normal file
Binary file not shown.
Loading…
Reference in a new issue