mirror of
https://github.com/bitwarden/android.git
synced 2026-06-06 06:17:21 -05:00
Add validity checks to ensure that changes to biometrics require a master password or pin to continue (#839)
This commit is contained in:
committed by
Álison Fernandes
parent
2623fc3cbe
commit
9a8aca9fe1
@@ -113,6 +113,14 @@ abstract class BaseDiskSource(
|
||||
value: String?,
|
||||
): Unit = sharedPreferences.edit { putString(key, value) }
|
||||
|
||||
protected fun removeWithPrefix(prefix: String) {
|
||||
sharedPreferences
|
||||
.all
|
||||
.keys
|
||||
.filter { it.startsWith(prefix) }
|
||||
.forEach { sharedPreferences.edit { remove(it) } }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BASE_KEY: String = "bwPreferencesStorage"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ interface SettingsDiskSource {
|
||||
*/
|
||||
val appThemeFlow: Flow<AppTheme>
|
||||
|
||||
/**
|
||||
* The currently persisted biometric integrity source for the system.
|
||||
*/
|
||||
var systemBiometricIntegritySource: String?
|
||||
|
||||
/**
|
||||
* The currently persisted setting for getting login item icons (or `null` if not set).
|
||||
*/
|
||||
@@ -43,6 +48,24 @@ interface SettingsDiskSource {
|
||||
*/
|
||||
fun clearData(userId: String)
|
||||
|
||||
/**
|
||||
* Retrieves the biometric integrity validity for the given [userId] and
|
||||
* [systemBioIntegrityState].
|
||||
*/
|
||||
fun getAccountBiometricIntegrityValidity(
|
||||
userId: String,
|
||||
systemBioIntegrityState: String,
|
||||
): Boolean?
|
||||
|
||||
/**
|
||||
* Stores the biometric integrity validity for the given [userId] and [systemBioIntegrityState].
|
||||
*/
|
||||
fun storeAccountBiometricIntegrityValidity(
|
||||
userId: String,
|
||||
systemBioIntegrityState: String,
|
||||
value: Boolean?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the last time the app synced the vault data for a given [userId] (or `null` if the
|
||||
* vault has never been synced).
|
||||
|
||||
@@ -27,6 +27,8 @@ private const val DISABLE_AUTOFILL_SAVE_PROMPT_KEY = "$BASE_KEY:autofillDisableS
|
||||
private const val DISABLE_ICON_LOADING_KEY = "$BASE_KEY:disableFavicon"
|
||||
private const val APPROVE_PASSWORDLESS_LOGINS_KEY = "$BASE_KEY:approvePasswordlessLogins"
|
||||
private const val SCREEN_CAPTURE_ALLOW_KEY = "$BASE_KEY:screenCaptureAllowed"
|
||||
private const val SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY = "$BASE_KEY:biometricIntegritySource"
|
||||
private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid"
|
||||
|
||||
/**
|
||||
* Primary implementation of [SettingsDiskSource].
|
||||
@@ -69,6 +71,12 @@ class SettingsDiskSourceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override var systemBiometricIntegritySource: String?
|
||||
get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY)
|
||||
set(value) {
|
||||
putString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY, value = value)
|
||||
}
|
||||
|
||||
override var appTheme: AppTheme
|
||||
get() = getString(key = APP_THEME_KEY)
|
||||
?.let { storedValue ->
|
||||
@@ -112,6 +120,26 @@ class SettingsDiskSourceImpl(
|
||||
)
|
||||
storeLastSyncTime(userId = userId, lastSyncTime = null)
|
||||
storeScreenCaptureAllowed(userId = userId, isScreenCaptureAllowed = null)
|
||||
removeWithPrefix(prefix = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_$userId")
|
||||
}
|
||||
|
||||
override fun getAccountBiometricIntegrityValidity(
|
||||
userId: String,
|
||||
systemBioIntegrityState: String,
|
||||
): Boolean? =
|
||||
getBoolean(
|
||||
key = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_${userId}_$systemBioIntegrityState",
|
||||
)
|
||||
|
||||
override fun storeAccountBiometricIntegrityValidity(
|
||||
userId: String,
|
||||
systemBioIntegrityState: String,
|
||||
value: Boolean?,
|
||||
) {
|
||||
putBoolean(
|
||||
key = "${ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY}_${userId}_$systemBioIntegrityState",
|
||||
value = value,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getLastSyncTime(userId: String): Instant? =
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
/**
|
||||
* Responsible for managing Android keystore encryption and decryption.
|
||||
*/
|
||||
interface BiometricsEncryptionManager {
|
||||
/**
|
||||
* Sets up biometrics to ensure future integrity checks work properly. If this method has never
|
||||
* been called [isBiometricIntegrityValid] will return false.
|
||||
*/
|
||||
fun setupBiometrics(userId: String)
|
||||
|
||||
/**
|
||||
* Checks to verify that the biometrics integrity is still valid. This returns `true` if the
|
||||
* biometrics data has not change since the app setup biometrics, `false` will be returned if
|
||||
* it has changed.
|
||||
*/
|
||||
fun isBiometricIntegrityValid(userId: String): Boolean
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import com.x8bit.bitwarden.BuildConfig
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import java.security.InvalidKeyException
|
||||
import java.security.KeyStore
|
||||
import java.security.UnrecoverableKeyException
|
||||
import java.util.UUID
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
|
||||
/**
|
||||
* Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption
|
||||
* and decryption.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
class BiometricsEncryptionManagerImpl(
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
) : BiometricsEncryptionManager {
|
||||
private val keystore = KeyStore
|
||||
.getInstance(ENCRYPTION_KEYSTORE_NAME)
|
||||
.also { it.load(null) }
|
||||
|
||||
private val keyGenParameterSpec: KeyGenParameterSpec
|
||||
get() = KeyGenParameterSpec
|
||||
.Builder(
|
||||
ENCRYPTION_KEY_NAME,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
|
||||
.setUserAuthenticationRequired(true)
|
||||
.setInvalidatedByBiometricEnrollment(true)
|
||||
.build()
|
||||
|
||||
override fun setupBiometrics(userId: String) {
|
||||
createIntegrityValues(userId)
|
||||
}
|
||||
|
||||
override fun isBiometricIntegrityValid(userId: String): Boolean =
|
||||
isSystemBiometricIntegrityValid(userId) && isAccountBiometricIntegrityValid(userId)
|
||||
|
||||
private fun isAccountBiometricIntegrityValid(userId: String): Boolean {
|
||||
val systemBioIntegrityState = settingsDiskSource
|
||||
.systemBiometricIntegritySource
|
||||
?: return false
|
||||
return settingsDiskSource
|
||||
.getAccountBiometricIntegrityValidity(
|
||||
userId = userId,
|
||||
systemBioIntegrityState = systemBioIntegrityState,
|
||||
)
|
||||
?: false
|
||||
}
|
||||
|
||||
private fun isSystemBiometricIntegrityValid(userId: String): Boolean =
|
||||
try {
|
||||
keystore.load(null)
|
||||
keystore
|
||||
.getKey(ENCRYPTION_KEY_NAME, null)
|
||||
?.let { Cipher.getInstance(CIPHER_TRANSFORMATION).init(Cipher.ENCRYPT_MODE, it) }
|
||||
true
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
// Biometric has changed
|
||||
settingsDiskSource.systemBiometricIntegritySource = null
|
||||
false
|
||||
} catch (e: UnrecoverableKeyException) {
|
||||
// Biometric was disabled and re-enabled
|
||||
settingsDiskSource.systemBiometricIntegritySource = null
|
||||
false
|
||||
} catch (e: InvalidKeyException) {
|
||||
// Fallback for old bitwarden users without a key
|
||||
createIntegrityValues(userId)
|
||||
true
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private fun createIntegrityValues(userId: String) {
|
||||
val systemBiometricIntegritySource = settingsDiskSource
|
||||
.systemBiometricIntegritySource
|
||||
?: UUID.randomUUID().toString()
|
||||
settingsDiskSource.systemBiometricIntegritySource = systemBiometricIntegritySource
|
||||
settingsDiskSource.storeAccountBiometricIntegrityValidity(
|
||||
userId = userId,
|
||||
systemBioIntegrityState = systemBiometricIntegritySource,
|
||||
value = true,
|
||||
)
|
||||
|
||||
try {
|
||||
val keyGen = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
ENCRYPTION_KEYSTORE_NAME,
|
||||
)
|
||||
keyGen.init(keyGenParameterSpec)
|
||||
keyGen.generateKey()
|
||||
} catch (e: Exception) {
|
||||
// Catch silently to allow biometrics to function on devices that are in
|
||||
// a state where key generation is not functioning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ENCRYPTION_KEYSTORE_NAME: String = "AndroidKeyStore"
|
||||
private const val ENCRYPTION_KEY_NAME: String = "${BuildConfig.APPLICATION_ID}.biometric_integrity"
|
||||
private const val CIPHER_TRANSFORMATION =
|
||||
KeyProperties.KEY_ALGORITHM_AES + "/" +
|
||||
KeyProperties.BLOCK_MODE_CBC + "/" +
|
||||
KeyProperties.ENCRYPTION_PADDING_PKCS7
|
||||
@@ -5,12 +5,15 @@ import android.content.Context
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager
|
||||
@@ -49,6 +52,14 @@ object PlatformManagerModule {
|
||||
@Singleton
|
||||
fun provideClock(): Clock = Clock.systemDefaultZone()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBiometricsEncryptionManager(
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
): BiometricsEncryptionManager = BiometricsEncryptionManagerImpl(
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBitwardenClipboardManager(
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
|
||||
@@ -35,13 +36,14 @@ private val DEFAULT_IS_SCREEN_CAPTURE_ALLOWED = BuildConfig.DEBUG
|
||||
/**
|
||||
* Primary implementation of [SettingsRepository].
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
@Suppress("TooManyFunctions", "LongParameterList")
|
||||
class SettingsRepositoryImpl(
|
||||
private val autofillManager: AutofillManager,
|
||||
private val appForegroundManager: AppForegroundManager,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val settingsDiskSource: SettingsDiskSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : SettingsRepository {
|
||||
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
|
||||
@@ -352,6 +354,7 @@ class SettingsRepositoryImpl(
|
||||
|
||||
override suspend fun setupBiometricsKey(): BiometricsKeyResult {
|
||||
val userId = activeUserId ?: return BiometricsKeyResult.Error
|
||||
biometricsEncryptionManager.setupBiometrics(userId)
|
||||
return vaultSdkSource
|
||||
.getUserEncryptionKey(userId = userId)
|
||||
.onSuccess {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl
|
||||
@@ -45,6 +46,7 @@ object PlatformRepositoryModule {
|
||||
authDiskSource: AuthDiskSource,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
encryptionManager: BiometricsEncryptionManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): SettingsRepository =
|
||||
SettingsRepositoryImpl(
|
||||
@@ -53,6 +55,7 @@ object PlatformRepositoryModule {
|
||||
authDiskSource = authDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
biometricsEncryptionManager = encryptionManager,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ fun VaultUnlockScreen(
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
if (state.isBiometricEnabled) {
|
||||
if (state.showBiometricLogin && biometricsManager.isBiometricsSupported) {
|
||||
BitwardenOutlinedButton(
|
||||
label = stringResource(id = R.string.use_biometrics_to_unlock),
|
||||
onClick = {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
@@ -39,12 +40,16 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
private val vaultRepo: VaultRepository,
|
||||
private val biometricsEncryptionManager: BiometricsEncryptionManager,
|
||||
environmentRepo: EnvironmentRepository,
|
||||
) : BaseViewModel<VaultUnlockState, VaultUnlockEvent, VaultUnlockAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val userState = requireNotNull(authRepository.userStateFlow.value)
|
||||
val accountSummaries = userState.toAccountSummaries()
|
||||
val activeAccountSummary = userState.toActiveAccountSummary()
|
||||
val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(
|
||||
userId = userState.activeUserId,
|
||||
)
|
||||
VaultUnlockState(
|
||||
accountSummaries = accountSummaries,
|
||||
avatarColorString = activeAccountSummary.avatarColorHex,
|
||||
@@ -54,6 +59,7 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
environmentUrl = environmentRepo.environment.label,
|
||||
input = "",
|
||||
isBiometricEnabled = userState.activeAccount.isBiometricsEnabled,
|
||||
isBiometricsValid = isBiometricsValid,
|
||||
vaultUnlockType = userState.activeAccount.vaultUnlockType,
|
||||
)
|
||||
},
|
||||
@@ -130,6 +136,10 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
|
||||
private fun handleBiometricsUnlockClick() {
|
||||
val activeUserId = authRepository.activeUserId ?: return
|
||||
if (!biometricsEncryptionManager.isBiometricIntegrityValid(activeUserId)) {
|
||||
mutableStateFlow.update { it.copy(isBiometricsValid = false) }
|
||||
return
|
||||
}
|
||||
mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) }
|
||||
viewModelScope.launch {
|
||||
val vaultUnlockResult = vaultRepo.unlockVaultWithBiometrics()
|
||||
@@ -221,6 +231,9 @@ class VaultUnlockViewModel @Inject constructor(
|
||||
|
||||
VaultUnlockResult.Success -> {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
if (state.isBiometricEnabled && !state.isBiometricsValid) {
|
||||
biometricsEncryptionManager.setupBiometrics(action.userId)
|
||||
}
|
||||
// Don't do anything, we'll navigate to the right place.
|
||||
}
|
||||
}
|
||||
@@ -263,6 +276,7 @@ data class VaultUnlockState(
|
||||
val environmentUrl: String,
|
||||
val dialog: VaultUnlockDialog?,
|
||||
val input: String,
|
||||
val isBiometricsValid: Boolean,
|
||||
val isBiometricEnabled: Boolean,
|
||||
val vaultUnlockType: VaultUnlockType,
|
||||
) : Parcelable {
|
||||
@@ -272,6 +286,11 @@ data class VaultUnlockState(
|
||||
*/
|
||||
val avatarColor: Color get() = avatarColorString.hexToColor()
|
||||
|
||||
/**
|
||||
* Indicates if we should display the button login with biometrics.
|
||||
*/
|
||||
val showBiometricLogin: Boolean get() = isBiometricEnabled && isBiometricsValid
|
||||
|
||||
/**
|
||||
* Represents the various dialogs the vault unlock screen can display.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user