PM-40154: feat: Migrate user data to KeystoreEncryptedSharedPreferences (#7158)

This commit is contained in:
David Perez
2026-08-11 13:04:42 +00:00
committed by GitHub
parent 11077ba0e9
commit 9d7979a501
15 changed files with 353 additions and 75 deletions
@@ -2,8 +2,10 @@ package com.x8bit.bitwarden
import android.app.Application
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.DiskSourceMigrationLogger
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
@@ -22,11 +24,17 @@ class BitwardenApplication : Application() {
// Inject classes here that must be triggered on startup but are not otherwise consumed by
// other callers.
@Inject
lateinit var fillAssistManager: FillAssistManager
lateinit var flightRecorder: FlightRecorderManager
@Inject
lateinit var logsManager: LogsManager
@Inject
lateinit var diskSourceMigrationLogger: DiskSourceMigrationLogger
@Inject
lateinit var fillAssistManager: FillAssistManager
@Inject
lateinit var networkConfigManager: NetworkConfigManager
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.time.Instant
import java.util.UUID
@@ -33,6 +34,7 @@ private const val BIOMETRICS_UNLOCK_KEY = "userKeyBiometricUnlock"
private const val USER_AUTO_UNLOCK_KEY_KEY = "userKeyAutoUnlock"
private const val DEVICE_KEY_KEY = "deviceKey"
private const val PENDING_ADMIN_AUTH_REQUEST_KEY = "pendingAdminAuthRequest"
private const val ACCOUNT_CRYPTOGRAPHIC_STATE_KEY = "accountCryptographicState"
// These keys should not be encrypted
private const val UNIQUE_APP_ID_KEY = "appId"
@@ -58,7 +60,6 @@ private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
private const val LAST_LOCK_TIMESTAMP = "lastLockTimestamp"
private const val PROFILE_ACCOUNT_KEYS_KEY = "profileAccountKeys"
private const val ACCOUNT_CRYPTOGRAPHIC_STATE_KEY = "accountCryptographicState"
private const val V2_UPGRADE_TOKEN = "v2UpgradeToken"
/**
@@ -67,11 +68,13 @@ private const val V2_UPGRADE_TOKEN = "v2UpgradeToken"
@Suppress("TooManyFunctions")
class AuthDiskSourceImpl(
encryptedSharedPreferences: SharedPreferences,
keystoreEncryptedPreferences: SharedPreferences,
sharedPreferences: SharedPreferences,
legacySecureStorageMigrator: LegacySecureStorageMigrator,
private val json: Json,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
keystoreEncryptedPreferences = keystoreEncryptedPreferences,
sharedPreferences = sharedPreferences,
),
AuthDiskSource {
@@ -132,6 +135,9 @@ class AuthDiskSourceImpl(
// We must migrate the Private Key and Account Keys to use the Account Cryptographic state
// from now on.
migrateAccountKeys()
// Migrate to the Keystore Encrypted SharedPreferences.
migrateToKeystoreEncryption()
}
override var authenticatorSyncSymmetricKey: ByteArray?
@@ -285,10 +291,7 @@ class AuthDiskSourceImpl(
}
override fun getUserAutoUnlockKey(userId: String): String? =
getEncryptedString(
key = USER_AUTO_UNLOCK_KEY_KEY.appendIdentifier(userId),
default = null,
)
getEncryptedString(key = USER_AUTO_UNLOCK_KEY_KEY.appendIdentifier(userId))
override fun storeUserAutoUnlockKey(
userId: String,
@@ -740,4 +743,20 @@ class AuthDiskSourceImpl(
}
}
}
private fun migrateToKeystoreEncryption() {
var isMigrated = false
isMigrated = migrateKeyByPrefix(keyPrefix = AUTHENTICATOR_SYNC_SYMMETRIC_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = AUTHENTICATOR_SYNC_UNLOCK_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = ACCOUNT_CRYPTOGRAPHIC_STATE_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = USER_AUTO_UNLOCK_KEY_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = DEVICE_KEY_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = PENDING_ADMIN_AUTH_REQUEST_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = BIOMETRICS_INIT_VECTOR_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = BIOMETRICS_UNLOCK_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = ACCOUNT_TOKENS_KEY) || isMigrated
if (isMigrated) {
Timber.d("AuthDiskSource has been migrated to keystore encrypted shared preferences")
}
}
}
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.di
import android.content.SharedPreferences
import com.bitwarden.data.datasource.disk.di.EncryptedPreferences
import com.bitwarden.data.datasource.disk.di.KeystoreEncryptedPreferences
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSourceImpl
@@ -24,12 +25,14 @@ object AuthDiskModule {
@Singleton
fun provideAuthDiskSource(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@KeystoreEncryptedPreferences keystoreEncryptedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
legacySecureStorageMigrator: LegacySecureStorageMigrator,
json: Json,
): AuthDiskSource =
AuthDiskSourceImpl(
encryptedSharedPreferences = encryptedSharedPreferences,
keystoreEncryptedPreferences = keystoreEncryptedPreferences,
sharedPreferences = sharedPreferences,
legacySecureStorageMigrator = legacySecureStorageMigrator,
json = json,
@@ -6,9 +6,9 @@ import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
import kotlinx.serialization.json.Json
import timber.log.Timber
private const val CONFIG_PREFIX = "elb_cookie_config"
private const val ENCRYPTED_PREFIX = "bwSecureStorage:$CONFIG_PREFIX"
/**
* Implementation of [CookieDiskSource] using encrypted SharedPreferences.
@@ -17,14 +17,21 @@ private const val ENCRYPTED_PREFIX = "bwSecureStorage:$CONFIG_PREFIX"
*/
class CookieDiskSourceImpl(
sharedPreferences: SharedPreferences,
private val encryptedSharedPreferences: SharedPreferences,
private val keystoreEncryptedPreferences: SharedPreferences,
encryptedSharedPreferences: SharedPreferences,
private val json: Json,
) : CookieDiskSource,
BaseEncryptedDiskSource(
sharedPreferences = sharedPreferences,
keystoreEncryptedPreferences = keystoreEncryptedPreferences,
encryptedSharedPreferences = encryptedSharedPreferences,
) {
init {
// Migrate to the Keystore Encrypted SharedPreferences.
migrateToKeystoreEncryption()
}
override fun getCookieConfig(hostname: String): CookieConfigurationData? {
val key = CONFIG_PREFIX.appendIdentifier(hostname)
return getEncryptedString(key)
@@ -37,12 +44,20 @@ class CookieDiskSourceImpl(
}
override fun clearCookies() {
val keysToRemove = encryptedSharedPreferences
val keysToRemove = keystoreEncryptedPreferences
.all
.keys
.filter { it.startsWith(ENCRYPTED_PREFIX) }
encryptedSharedPreferences.edit {
.filter { it.startsWith(CONFIG_PREFIX) }
keystoreEncryptedPreferences.edit {
keysToRemove.forEach { key -> remove(key) }
}
}
private fun migrateToKeystoreEncryption() {
var isMigrated = false
isMigrated = migrateKeyByPrefix(keyPrefix = CONFIG_PREFIX) || isMigrated
if (isMigrated) {
Timber.d("CookieDiskSource has been migrated to keystore encrypted shared preferences")
}
}
}
@@ -7,7 +7,9 @@ import androidx.room.Room
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
import com.bitwarden.data.datasource.disk.di.EncryptedPreferences
import com.bitwarden.data.datasource.disk.di.KeystoreEncryptedPreferences
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
@@ -22,6 +24,8 @@ 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.dao.OrganizationEventDao
import com.x8bit.bitwarden.data.platform.datasource.disk.database.PlatformDatabase
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.DiskSourceMigrationLogger
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.DiskSourceMigrationLoggerImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigratorImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorage
@@ -114,6 +118,20 @@ object PlatformDiskModule {
encryptedSharedPreferences = encryptedSharedPreferences,
)
@Provides
@Singleton
fun provideDiskSourceMigrationLogger(
flightRecorderManager: FlightRecorderManager,
@EncryptedPreferences encryptedPreferences: SharedPreferences,
@KeystoreEncryptedPreferences keystoreEncryptedPreferences: SharedPreferences,
dispatcherManager: DispatcherManager,
): DiskSourceMigrationLogger = DiskSourceMigrationLoggerImpl(
flightRecorderManager = flightRecorderManager,
encryptedPreferences = encryptedPreferences,
keystoreEncryptedPreferences = keystoreEncryptedPreferences,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideLegacyAppCenterMigrator(
@@ -162,10 +180,12 @@ object PlatformDiskModule {
@Singleton
fun provideCookieDiskSource(
@UnencryptedPreferences sharedPreferences: SharedPreferences,
@KeystoreEncryptedPreferences keystoreEncryptedPreferences: SharedPreferences,
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
json: Json,
): CookieDiskSource = CookieDiskSourceImpl(
sharedPreferences = sharedPreferences,
keystoreEncryptedPreferences = keystoreEncryptedPreferences,
encryptedSharedPreferences = encryptedSharedPreferences,
json = json,
)
@@ -0,0 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.legacy
/**
* Adds a log indicating if the data encrypted data stored in shared preferences has been migrated.
*/
interface DiskSourceMigrationLogger
@@ -0,0 +1,40 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.legacy
import android.content.SharedPreferences
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
/**
* The default implementation of the [DiskSourceMigrationLogger].
*/
@OmitFromCoverage
internal class DiskSourceMigrationLoggerImpl(
keystoreEncryptedPreferences: SharedPreferences,
encryptedPreferences: SharedPreferences,
flightRecorderManager: FlightRecorderManager,
dispatcherManager: DispatcherManager,
) : DiskSourceMigrationLogger {
private val unconfinedScope: CoroutineScope = CoroutineScope(dispatcherManager.unconfined)
init {
flightRecorderManager
.isLoggingReadyFlow
.filter { it }
.onEach {
// The older encrypted shared preferences has its own values written to disk,
// so it is normal for there to be 2 values still present.
if (keystoreEncryptedPreferences.all.isNotEmpty() &&
encryptedPreferences.all.size <= 2
) {
Timber.d("Encrypted data has been migrated to Keystore encryption.")
}
}
.launchIn(unconfinedScope)
}
}
@@ -39,6 +39,7 @@ import java.time.Instant
@Suppress("LargeClass")
class AuthDiskSourceTest {
private val fakeEncryptedSharedPreferences = FakeSharedPreferences()
private val fakeKeystoreEncryptedPreferences = FakeSharedPreferences()
private val fakeSharedPreferences = FakeSharedPreferences().apply {
edit(commit = true) {
putString("bwPreferencesStorage:masterKeyEncryptedUserKey", "testUserKey")
@@ -52,6 +53,7 @@ class AuthDiskSourceTest {
private val authDiskSource = AuthDiskSourceImpl(
encryptedSharedPreferences = fakeEncryptedSharedPreferences,
keystoreEncryptedPreferences = fakeKeystoreEncryptedPreferences,
sharedPreferences = fakeSharedPreferences,
legacySecureStorageMigrator = legacySecureStorageMigrator,
json = json,
@@ -62,6 +64,42 @@ class AuthDiskSourceTest {
verify(exactly = 1) { legacySecureStorageMigrator.migrateIfNecessary() }
}
@Test
@Suppress("MaxLineLength")
fun `initialization should migrate legacy encrypted values to the keystore encrypted preferences`() {
val mockUserId = "mockUserId"
val legacyValues = mapOf(
"authenticatorSyncSymmetric" to "mockSyncSymmetricKey",
"authenticatorSyncUnlock_$mockUserId" to "mockSyncUnlockKey",
"accountCryptographicState_$mockUserId" to "mockCryptographicState",
"userKeyAutoUnlock_$mockUserId" to "mockUserAutoUnlockKey",
"deviceKey_$mockUserId" to "mockDeviceKey",
"pendingAdminAuthRequest_$mockUserId" to "mockPendingAuthRequest",
"biometricInitializationVector_$mockUserId" to "mockInitVector",
"userKeyBiometricUnlock_$mockUserId" to "mockBiometricsKey",
"accountTokens_$mockUserId" to "mockAccountTokens",
)
fakeEncryptedSharedPreferences.edit {
legacyValues.forEach { (key, value) ->
putString("bwSecureStorage:$key", value)
}
}
AuthDiskSourceImpl(
encryptedSharedPreferences = fakeEncryptedSharedPreferences,
keystoreEncryptedPreferences = fakeKeystoreEncryptedPreferences,
sharedPreferences = fakeSharedPreferences,
legacySecureStorageMigrator = legacySecureStorageMigrator,
json = json,
)
// The values are moved to the keystore encrypted preferences without the legacy prefix.
assertTrue(fakeEncryptedSharedPreferences.all.isEmpty())
legacyValues.forEach { (key, value) ->
assertEquals(value, fakeKeystoreEncryptedPreferences.getString(key, null))
}
}
@Test
fun `initialization should trigger a legacy user key removal`() {
assertNull(
@@ -455,12 +493,12 @@ class AuthDiskSourceTest {
@Test
fun `getAccountCryptographicState should pull from SharedPreferences`() {
val accountKeysBaseKey = "bwSecureStorage:accountCryptographicState"
val accountKeysBaseKey = "accountCryptographicState"
val mockUserId = "mockUserId"
val mockAccountCryptographicState = WrappedAccountCryptographicState.V1(
privateKey = "privateKey",
)
fakeEncryptedSharedPreferences.edit {
fakeKeystoreEncryptedPreferences.edit {
putString(
"${accountKeysBaseKey}_$mockUserId",
json.encodeToString(
@@ -478,7 +516,7 @@ class AuthDiskSourceTest {
@Test
fun `storeAccountCryptographicState should update sharedPreferences`() {
val accountKeysBaseKey = "bwSecureStorage:accountCryptographicState"
val accountKeysBaseKey = "accountCryptographicState"
val mockUserId = "mockUserId"
val mockAccountCryptographicState = WrappedAccountCryptographicState.V1(
privateKey = "privateKey",
@@ -487,7 +525,7 @@ class AuthDiskSourceTest {
userId = mockUserId,
accountCryptographicState = mockAccountCryptographicState,
)
val actual = fakeEncryptedSharedPreferences.getString(
val actual = fakeKeystoreEncryptedPreferences.getString(
"${accountKeysBaseKey}_$mockUserId",
null,
)
@@ -580,10 +618,10 @@ class AuthDiskSourceTest {
@Test
fun `getUserAutoUnlockKey should pull from SharedPreferences`() {
val userAutoUnlockKeyBaseKey = "bwSecureStorage:userKeyAutoUnlock"
val userAutoUnlockKeyBaseKey = "userKeyAutoUnlock"
val mockUserId = "mockUserId"
val mockUserAutoUnlockKey = "mockUserAutoUnlockKey"
fakeEncryptedSharedPreferences
fakeKeystoreEncryptedPreferences
.edit {
putString(
"${userAutoUnlockKeyBaseKey}_$mockUserId",
@@ -599,14 +637,14 @@ class AuthDiskSourceTest {
@Test
fun `storeUserAutoUnlockKey should update SharedPreferences`() {
val userAutoUnlockKeyBaseKey = "bwSecureStorage:userKeyAutoUnlock"
val userAutoUnlockKeyBaseKey = "userKeyAutoUnlock"
val mockUserId = "mockUserId"
val mockUserAutoUnlockKey = "mockUserAutoUnlockKey"
authDiskSource.storeUserAutoUnlockKey(
userId = mockUserId,
userAutoUnlockKey = mockUserAutoUnlockKey,
)
val actual = fakeEncryptedSharedPreferences
val actual = fakeKeystoreEncryptedPreferences
.getString(
"${userAutoUnlockKeyBaseKey}_$mockUserId",
null,
@@ -619,23 +657,23 @@ class AuthDiskSourceTest {
@Test
fun `getDeviceKey should pull from SharedPreferences`() {
val deviceKeyBaseKey = "bwSecureStorage:deviceKey"
val deviceKeyBaseKey = "deviceKey"
val mockUserId = "mockUserId"
val deviceKeyKey = "${deviceKeyBaseKey}_$mockUserId"
val devicesKey = "1234"
fakeEncryptedSharedPreferences.edit { putString(deviceKeyKey, devicesKey) }
fakeKeystoreEncryptedPreferences.edit { putString(deviceKeyKey, devicesKey) }
val actual = authDiskSource.getDeviceKey(userId = mockUserId)
assertEquals(devicesKey, actual)
}
@Test
fun `storeDeviceKey for non-null values should update SharedPreferences`() {
val deviceKeyBaseKey = "bwSecureStorage:deviceKey"
val deviceKeyBaseKey = "deviceKey"
val mockUserId = "mockUserId"
val deviceKeyKey = "${deviceKeyBaseKey}_$mockUserId"
val devicesKey = "1234"
authDiskSource.storeDeviceKey(userId = mockUserId, deviceKey = devicesKey)
val actual = fakeEncryptedSharedPreferences.getString(
val actual = fakeKeystoreEncryptedPreferences.getString(
key = deviceKeyKey,
defaultValue = null,
)
@@ -644,21 +682,21 @@ class AuthDiskSourceTest {
@Test
fun `storeDeviceKey for null values should clear SharedPreferences`() {
val deviceKeyBaseKey = "bwSecureStorage:deviceKey"
val deviceKeyBaseKey = "deviceKey"
val mockUserId = "mockUserId"
val deviceKeyKey = "${deviceKeyBaseKey}_$mockUserId"
val deviceKey = "1234"
fakeEncryptedSharedPreferences.edit { putString(deviceKeyKey, deviceKey) }
fakeKeystoreEncryptedPreferences.edit { putString(deviceKeyKey, deviceKey) }
authDiskSource.storeDeviceKey(userId = mockUserId, deviceKey = null)
assertFalse(fakeEncryptedSharedPreferences.contains(deviceKeyKey))
assertFalse(fakeKeystoreEncryptedPreferences.contains(deviceKeyKey))
}
@Test
fun `getPendingAuthRequest should pull from SharedPreferences`() {
val pendingAdminAuthRequestBaseKey = "bwSecureStorage:pendingAdminAuthRequest"
val pendingAdminAuthRequestBaseKey = "pendingAdminAuthRequest"
val mockUserId = "mockUserId"
val pendingAdminAuthRequestKey = "${pendingAdminAuthRequestBaseKey}_$mockUserId"
fakeEncryptedSharedPreferences.edit {
fakeKeystoreEncryptedPreferences.edit {
putString(
pendingAdminAuthRequestKey,
"""
@@ -685,7 +723,7 @@ class AuthDiskSourceTest {
@Test
fun `storePendingAuthRequest for non-null values should update SharedPreferences`() {
val pendingAdminAuthRequestKeyBaseKey = "bwSecureStorage:pendingAdminAuthRequest"
val pendingAdminAuthRequestKeyBaseKey = "pendingAdminAuthRequest"
val mockUserId = "mockUserId"
val pendingAuthRequestKey = "${pendingAdminAuthRequestKeyBaseKey}_$mockUserId"
val pendingAdminAuthRequest = PendingAuthRequestJson(
@@ -698,7 +736,7 @@ class AuthDiskSourceTest {
userId = mockUserId,
pendingAuthRequest = pendingAdminAuthRequest,
)
val actual = fakeEncryptedSharedPreferences.getString(
val actual = fakeKeystoreEncryptedPreferences.getString(
key = pendingAuthRequestKey,
defaultValue = null,
)
@@ -720,11 +758,11 @@ class AuthDiskSourceTest {
@Test
fun `getUserBiometricUnlockKey should pull from SharedPreferences`() {
val biometricsKeyBaseKey = "bwSecureStorage:userKeyBiometricUnlock"
val biometricsKeyBaseKey = "userKeyBiometricUnlock"
val mockUserId = "mockUserId"
val biometricsKeyKey = "${biometricsKeyBaseKey}_$mockUserId"
val biometricsKey = "1234"
fakeEncryptedSharedPreferences.edit {
fakeKeystoreEncryptedPreferences.edit {
putString(biometricsKeyKey, biometricsKey)
}
val actual = authDiskSource.getUserBiometricUnlockKey(userId = mockUserId)
@@ -752,12 +790,12 @@ class AuthDiskSourceTest {
@Test
fun `storeUserBiometricInitVector for non-null values should update SharedPreferences`() {
val biometricsInitVectorBaseKey = "bwSecureStorage:biometricInitializationVector"
val biometricsInitVectorBaseKey = "biometricInitializationVector"
val mockUserId = "mockUserId"
val biometricsInitVectorKey = "${biometricsInitVectorBaseKey}_$mockUserId"
val initVector = byteArrayOf(1, 2)
authDiskSource.storeUserBiometricInitVector(userId = mockUserId, iv = initVector)
val actual = fakeEncryptedSharedPreferences.getString(
val actual = fakeKeystoreEncryptedPreferences.getString(
key = biometricsInitVectorKey,
defaultValue = null,
)
@@ -766,20 +804,20 @@ class AuthDiskSourceTest {
@Test
fun `storeUserBiometricInitVector for null values should clear SharedPreferences`() {
val biometricsInitVectorBaseKey = "bwSecureStorage:biometricInitializationVector"
val biometricsInitVectorBaseKey = "biometricInitializationVector"
val mockUserId = "mockUserId"
val biometricsInitVectorKey = "${biometricsInitVectorBaseKey}_$mockUserId"
val initVector = "1234"
fakeEncryptedSharedPreferences.edit {
fakeKeystoreEncryptedPreferences.edit {
putString(biometricsInitVectorKey, initVector)
}
authDiskSource.storeUserBiometricInitVector(userId = mockUserId, iv = null)
assertFalse(fakeEncryptedSharedPreferences.contains(biometricsInitVectorKey))
assertFalse(fakeKeystoreEncryptedPreferences.contains(biometricsInitVectorKey))
}
@Test
fun `storeUserBiometricUnlockKey for non-null values should update SharedPreferences`() {
val biometricsKeyBaseKey = "bwSecureStorage:userKeyBiometricUnlock"
val biometricsKeyBaseKey = "userKeyBiometricUnlock"
val mockUserId = "mockUserId"
val biometricsKeyKey = "${biometricsKeyBaseKey}_$mockUserId"
val biometricsKey = "1234"
@@ -787,7 +825,7 @@ class AuthDiskSourceTest {
userId = mockUserId,
biometricsKey = biometricsKey,
)
val actual = fakeEncryptedSharedPreferences.getString(
val actual = fakeKeystoreEncryptedPreferences.getString(
key = biometricsKeyKey,
defaultValue = null,
)
@@ -796,18 +834,18 @@ class AuthDiskSourceTest {
@Test
fun `storeUserBiometricUnlockKey for null values should clear SharedPreferences`() {
val biometricsKeyBaseKey = "bwSecureStorage:userKeyBiometricUnlock"
val biometricsKeyBaseKey = "userKeyBiometricUnlock"
val mockUserId = "mockUserId"
val biometricsKeyKey = "${biometricsKeyBaseKey}_$mockUserId"
val biometricsKey = "1234"
fakeEncryptedSharedPreferences.edit {
fakeKeystoreEncryptedPreferences.edit {
putString(biometricsKeyKey, biometricsKey)
}
authDiskSource.storeUserBiometricUnlockKey(
userId = mockUserId,
biometricsKey = null,
)
assertFalse(fakeEncryptedSharedPreferences.contains(biometricsKeyKey))
assertFalse(fakeKeystoreEncryptedPreferences.contains(biometricsKeyKey))
}
@Suppress("MaxLineLength")
@@ -1233,13 +1271,13 @@ class AuthDiskSourceTest {
@Test
fun `getAccountTokens should pull from SharedPreferences`() {
val baseKey = "bwSecureStorage:accountTokens"
val baseKey = "accountTokens"
val mockUserId = "mockUserId"
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
)
fakeEncryptedSharedPreferences.edit {
fakeKeystoreEncryptedPreferences.edit {
putString("${baseKey}_$mockUserId", json.encodeToString(accountTokens))
}
val actual = authDiskSource.getAccountTokens(userId = mockUserId)
@@ -1276,7 +1314,7 @@ class AuthDiskSourceTest {
@Test
fun `storeAccountTokens should update SharedPreferences`() {
val baseKey = "bwSecureStorage:accountTokens"
val baseKey = "accountTokens"
val mockUserId = "mockUserId"
val accountTokens = AccountTokensJson(
accessToken = "accessToken",
@@ -1286,7 +1324,7 @@ class AuthDiskSourceTest {
userId = mockUserId,
accountTokens = accountTokens,
)
val actual = fakeEncryptedSharedPreferences.getString(
val actual = fakeKeystoreEncryptedPreferences.getString(
key = "${baseKey}_$mockUserId",
defaultValue = null,
)
@@ -1298,10 +1336,10 @@ class AuthDiskSourceTest {
@Test
fun `getAuthenticatorSyncUnlockKey should pull from SharedPreferences`() {
val authenticatorSyncUnlockKey = "bwSecureStorage:authenticatorSyncUnlock"
val authenticatorSyncUnlockKey = "authenticatorSyncUnlock"
val mockUserId = "mockUserId"
val mockAuthenticatorSyncUnlockKey = "mockAuthSyncUnlockKey"
fakeEncryptedSharedPreferences
fakeKeystoreEncryptedPreferences
.edit {
putString(
"${authenticatorSyncUnlockKey}_$mockUserId",
@@ -1317,7 +1355,7 @@ class AuthDiskSourceTest {
@Test
fun `storeAuthenticatorSyncUnlockKey should update SharedPreferences`() {
val authenticatorSyncUnlockKey = "bwSecureStorage:authenticatorSyncUnlock"
val authenticatorSyncUnlockKey = "authenticatorSyncUnlock"
val mockUserId = "mockUserId"
val mockAuthenticatorSyncUnlockKey = "mockAuthSyncUnlockKey"
authDiskSource.storeAuthenticatorSyncUnlockKey(
@@ -1325,7 +1363,7 @@ class AuthDiskSourceTest {
authenticatorSyncUnlockKey = mockAuthenticatorSyncUnlockKey,
)
val actual = fakeEncryptedSharedPreferences.getString(
val actual = fakeKeystoreEncryptedPreferences.getString(
key = "${authenticatorSyncUnlockKey}_$mockUserId",
defaultValue = null,
)
@@ -1385,18 +1423,18 @@ class AuthDiskSourceTest {
@Test
fun `authenticatorSyncSymmetricKey should store and update from EncryptedSharedPreferences`() {
val sharedPrefsKey = "bwSecureStorage:authenticatorSyncSymmetric"
val sharedPrefsKey = "authenticatorSyncSymmetric"
// Shared preferences and the repository start with the same value:
assertNull(authDiskSource.authenticatorSyncSymmetricKey)
assertNull(fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null))
assertNull(fakeKeystoreEncryptedPreferences.getString(sharedPrefsKey, null))
// Updating the repository updates shared preferences:
val symmetricKey = generateSecretKey().getOrThrow().encoded
authDiskSource.authenticatorSyncSymmetricKey = symmetricKey
assertEquals(
symmetricKey.toString(Charsets.ISO_8859_1),
fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null),
fakeKeystoreEncryptedPreferences.getString(sharedPrefsKey, null),
)
// Retrieving the key from repository should give same byte array despite String conversion:
@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import androidx.core.content.edit
import com.bitwarden.core.di.CoreModule
import com.bitwarden.data.datasource.disk.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
@@ -10,15 +11,58 @@ import org.junit.jupiter.api.Test
class CookieDiskSourceTest {
private val fakeEncryptedSharedPreferences = FakeSharedPreferences()
private val fakeKeystoreEncryptedPreferences = FakeSharedPreferences()
private val fakeSharedPreferences = FakeSharedPreferences()
private val json = CoreModule.providesJson(buildInfoManager = mockk(relaxed = true))
private val cookieDiskSource: CookieDiskSource = CookieDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
keystoreEncryptedPreferences = fakeKeystoreEncryptedPreferences,
encryptedSharedPreferences = fakeEncryptedSharedPreferences,
json = json,
)
@Test
@Suppress("MaxLineLength")
fun `initialization should migrate legacy encrypted values to the keystore encrypted preferences`() {
val hostname = "vault.bitwarden.com"
val config = CookieConfigurationData(
hostname = hostname,
cookies = listOf(
CookieConfigurationData.Cookie(
name = "BW_SESSION",
value = "encrypted_cookie_value",
),
),
)
val legacyKey = "bwSecureStorage:elb_cookie_config_$hostname"
fakeEncryptedSharedPreferences.edit {
putString(legacyKey, json.encodeToString(config))
}
val diskSource = CookieDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
keystoreEncryptedPreferences = fakeKeystoreEncryptedPreferences,
encryptedSharedPreferences = fakeEncryptedSharedPreferences,
json = json,
)
// The value is moved to the keystore encrypted preferences without the legacy prefix.
assertNull(fakeEncryptedSharedPreferences.getString(legacyKey, null))
assertEquals(
json.parseToJsonElement(json.encodeToString(config)),
json.parseToJsonElement(
requireNotNull(
fakeKeystoreEncryptedPreferences.getString(
"elb_cookie_config_$hostname",
null,
),
),
),
)
assertEquals(config, diskSource.getCookieConfig(hostname))
}
@Test
fun `getCookieConfig should return null when no config exists`() {
assertNull(cookieDiskSource.getCookieConfig("example.com"))
@@ -5,6 +5,7 @@ import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
import timber.log.Timber
import java.util.UUID
private const val AUTHENTICATOR_SYNC_SYMMETRIC_KEY = "authenticatorSyncSymmetricKey"
@@ -18,14 +19,21 @@ private const val UNIQUE_APP_ID_KEY = "appId"
*/
class AuthDiskSourceImpl(
encryptedSharedPreferences: SharedPreferences,
keystoreEncryptedPreferences: SharedPreferences,
sharedPreferences: SharedPreferences,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
keystoreEncryptedPreferences = keystoreEncryptedPreferences,
sharedPreferences = sharedPreferences,
),
AuthDiskSource {
private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)
init {
// Migrate to the Keystore Encrypted SharedPreferences.
migrateToKeystoreEncryption()
}
override val uniqueAppId: String
get() = getString(key = UNIQUE_APP_ID_KEY) ?: generateAndStoreUniqueAppId()
@@ -86,4 +94,14 @@ class AuthDiskSourceImpl(
.also {
putString(key = UNIQUE_APP_ID_KEY, value = it)
}
private fun migrateToKeystoreEncryption() {
var isMigrated = false
isMigrated = migrateKeyByPrefix(keyPrefix = BIOMETRICS_UNLOCK_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = BIOMETRICS_INIT_VECTOR_KEY) || isMigrated
isMigrated = migrateKeyByPrefix(keyPrefix = AUTHENTICATOR_SYNC_SYMMETRIC_KEY) || isMigrated
if (isMigrated) {
Timber.d("AuthDiskSource has been migrated to keystore encrypted shared preferences")
}
}
}
@@ -4,6 +4,7 @@ import android.content.SharedPreferences
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSourceImpl
import com.bitwarden.data.datasource.disk.di.EncryptedPreferences
import com.bitwarden.data.datasource.disk.di.KeystoreEncryptedPreferences
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
import dagger.Module
import dagger.Provides
@@ -22,10 +23,12 @@ object AuthDiskModule {
@Singleton
fun provideAuthDiskSource(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@KeystoreEncryptedPreferences keystoreEncryptedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
): AuthDiskSource =
AuthDiskSourceImpl(
encryptedSharedPreferences = encryptedSharedPreferences,
keystoreEncryptedPreferences = keystoreEncryptedPreferences,
sharedPreferences = sharedPreferences,
)
}
@@ -11,28 +11,61 @@ import org.junit.jupiter.api.Test
class AuthDiskSourceTest {
private val fakeEncryptedSharedPreferences = FakeSharedPreferences()
private val fakeKeystoreEncryptedPreferences = FakeSharedPreferences()
private val fakeSharedPreferences = FakeSharedPreferences()
private val authDiskSource = AuthDiskSourceImpl(
encryptedSharedPreferences = fakeEncryptedSharedPreferences,
keystoreEncryptedPreferences = fakeKeystoreEncryptedPreferences,
sharedPreferences = fakeSharedPreferences,
)
@Test
@Suppress("MaxLineLength")
fun `authenticatorBridgeSymmetricSyncKey should store and update from EncryptedSharedPreferences`() {
val sharedPrefsKey = "bwSecureStorage:authenticatorSyncSymmetricKey"
fun `initialization should migrate legacy encrypted values to the keystore encrypted preferences`() {
fakeEncryptedSharedPreferences.edit {
putString("bwSecureStorage:userKeyBiometricUnlock", "biometricsKey")
putString("bwSecureStorage:biometricInitializationVector", "initVector")
putString("bwSecureStorage:authenticatorSyncSymmetricKey", "symmetricKey")
}
AuthDiskSourceImpl(
encryptedSharedPreferences = fakeEncryptedSharedPreferences,
keystoreEncryptedPreferences = fakeKeystoreEncryptedPreferences,
sharedPreferences = fakeSharedPreferences,
)
// The values are moved to the keystore encrypted preferences without the legacy prefix.
assertTrue(fakeEncryptedSharedPreferences.all.isEmpty())
assertEquals(
"biometricsKey",
fakeKeystoreEncryptedPreferences.getString("userKeyBiometricUnlock", null),
)
assertEquals(
"initVector",
fakeKeystoreEncryptedPreferences.getString("biometricInitializationVector", null),
)
assertEquals(
"symmetricKey",
fakeKeystoreEncryptedPreferences.getString("authenticatorSyncSymmetricKey", null),
)
}
@Test
@Suppress("MaxLineLength")
fun `authenticatorBridgeSymmetricSyncKey should store and update from keystore encrypted preferences`() {
val sharedPrefsKey = "authenticatorSyncSymmetricKey"
// Shared preferences and the repository start with the same value:
assertNull(authDiskSource.authenticatorBridgeSymmetricSyncKey)
assertNull(fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null))
assertNull(fakeKeystoreEncryptedPreferences.getString(sharedPrefsKey, null))
// Updating the repository updates shared preferences:
val symmetricKey = generateSecretKey().getOrThrow().encoded
authDiskSource.authenticatorBridgeSymmetricSyncKey = symmetricKey
assertEquals(
symmetricKey.toString(Charsets.ISO_8859_1),
fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null),
fakeKeystoreEncryptedPreferences.getString(sharedPrefsKey, null),
)
// Retrieving the key from repository should give same byte array despite String conversion:
@@ -40,19 +73,20 @@ class AuthDiskSourceTest {
}
@Test
fun `userBiometricKeyInitVector should store and update from EncryptedSharedPreferences`() {
val sharedPrefsKey = "bwSecureStorage:biometricInitializationVector"
@Suppress("MaxLineLength")
fun `userBiometricKeyInitVector should store and update from keystore encrypted preferences`() {
val sharedPrefsKey = "biometricInitializationVector"
// Shared preferences and the repository start with the same value:
assertNull(authDiskSource.userBiometricKeyInitVector)
assertNull(fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null))
assertNull(fakeKeystoreEncryptedPreferences.getString(sharedPrefsKey, null))
// Updating the repository updates shared preferences:
val userBiometricKeyInitVector = byteArrayOf(3, 4)
authDiskSource.userBiometricKeyInitVector = userBiometricKeyInitVector
assertEquals(
userBiometricKeyInitVector.toString(Charsets.ISO_8859_1),
fakeEncryptedSharedPreferences.getString(sharedPrefsKey, null),
fakeKeystoreEncryptedPreferences.getString(sharedPrefsKey, null),
)
// Retrieving the key from repository should give same byte array despite String conversion:
@@ -2,31 +2,49 @@ package com.bitwarden.data.datasource.disk
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import timber.log.Timber
private const val LEGACY_PREFIX: String = "bwSecureStorage:"
/**
* Base class for simplifying interactions with [SharedPreferences] and
* [EncryptedSharedPreferences].
* Base class for simplifying interactions with [SharedPreferences], this includes both regular
* and encrypted storage.
*/
@Suppress("UnnecessaryAbstractClass")
abstract class BaseEncryptedDiskSource(
sharedPreferences: SharedPreferences,
private val encryptedSharedPreferences: SharedPreferences,
) : BaseDiskSource(
sharedPreferences = sharedPreferences,
) {
private val keystoreEncryptedPreferences: SharedPreferences,
) : BaseDiskSource(sharedPreferences = sharedPreferences) {
protected fun getEncryptedString(
key: String,
default: String? = null,
): String? = encryptedSharedPreferences.getString(key.withBase(), default)
): String? = keystoreEncryptedPreferences.getString(key, null)
protected fun putEncryptedString(
key: String,
value: String?,
): Unit = encryptedSharedPreferences.edit { putString(key.withBase(), value) }
}
) {
keystoreEncryptedPreferences.edit { putString(key, value) }
}
/**
* Helper method for prepending the key with the appropriate base storage key.
*/
private fun String.withBase(): String = "bwSecureStorage:$this"
protected fun migrateKeyByPrefix(keyPrefix: String): Boolean {
var result = false
encryptedSharedPreferences
.all
.keys
.filter { it.startsWith(prefix = "$LEGACY_PREFIX$keyPrefix") }
.forEach { key ->
Timber.d("Migrating $key to keystore encrypted shared preferences")
result = true
// Move the value to the new file without the base prefix.
encryptedSharedPreferences.getString(key, null)?.let { value ->
keystoreEncryptedPreferences.edit(commit = true) {
putString(key.removePrefix(prefix = LEGACY_PREFIX), value)
}
}
// Then ensure the original value is deleted.
encryptedSharedPreferences.edit(commit = true) { remove(key) }
}
return result
}
}
@@ -18,6 +18,13 @@ interface FlightRecorderManager {
*/
val flightRecorderDataFlow: StateFlow<FlightRecorderDataSet>
/**
* Emits `true` when the Flight Recorder is initialized and capable of logging, `false`
* otherwise. This can emit true even is the Flight Recorder is disabled, it only indicates
* that it could be logging.
*/
val isLoggingReadyFlow: StateFlow<Boolean>
/**
* Dismisses the all flight recorder banners.
*/
@@ -13,6 +13,7 @@ import com.bitwarden.data.manager.model.FlightRecorderDuration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
@@ -42,6 +43,9 @@ internal class FlightRecorderManagerImpl(
private var cancellationJob: Job = Job().apply { complete() }
private val expirationJobMap: MutableMap<String, Job> = concurrentMapOf()
private val flightRecorderTree = FlightRecorderTree()
private val mutableIsLoggingReadyFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val isLoggingReadyFlow: StateFlow<Boolean> get() = mutableIsLoggingReadyFlow
override val flightRecorderData: FlightRecorderDataSet
get() = flightRecorderDiskSource
@@ -71,6 +75,7 @@ internal class FlightRecorderManagerImpl(
ScreenStateBroadcastReceiver(),
IntentFilter(Intent.ACTION_SCREEN_ON),
)
mutableIsLoggingReadyFlow.value = true
}
override fun dismissFlightRecorderBanner() {