Vault repo biometrics (#821)

This commit is contained in:
David Perez 2024-01-27 22:50:13 -06:00 committed by Álison Fernandes
parent aacb955720
commit 2dde22f762
8 changed files with 314 additions and 6 deletions

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@ -169,6 +170,17 @@ interface SettingsRepository {
*/
fun storePullToRefreshEnabled(isPullToRefreshEnabled: Boolean)
/**
* Clears any previously stored encrypted user key used with biometrics for the current user.
*/
fun clearBiometricsKey()
/**
* Stores the encrypted user key for biometrics, allowing it to be used to unlock the current
* user's vault.
*/
suspend fun setupBiometricsKey(): BiometricsKeyResult
/**
* Stores the given PIN, allowing it to be used to unlock the current user's vault.
*

View file

@ -7,6 +7,7 @@ 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.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@ -349,6 +350,24 @@ class SettingsRepositoryImpl(
}
}
override suspend fun setupBiometricsKey(): BiometricsKeyResult {
val userId = activeUserId ?: return BiometricsKeyResult.Error
return vaultSdkSource
.getUserEncryptionKey(userId = userId)
.onSuccess {
authDiskSource.storeUserBiometricUnlockKey(userId = userId, biometricsKey = it)
}
.fold(
onSuccess = { BiometricsKeyResult.Success },
onFailure = { BiometricsKeyResult.Error },
)
}
override fun clearBiometricsKey() {
val userId = activeUserId ?: return
authDiskSource.storeUserBiometricUnlockKey(userId = userId, biometricsKey = null)
}
override fun storeUnlockPin(
pin: String,
shouldRequireMasterPasswordOnRestart: Boolean,

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.platform.repository.model
/**
* Models result of setting up a biometrics key.
*/
sealed class BiometricsKeyResult {
/**
* Biometrics key setup successfully.
*/
data object Success : BiometricsKeyResult()
/**
* Generic error while setting up the biometrics key.
*/
data object Error : BiometricsKeyResult()
}

View file

@ -155,6 +155,11 @@ interface VaultRepository : VaultLockManager {
*/
fun emitTotpCodeResult(totpCodeResult: TotpCodeResult)
/**
* Attempt to unlock the vault using the stored biometric key for the currently active user.
*/
suspend fun unlockVaultWithBiometrics(): VaultUnlockResult
/**
* Attempt to unlock the vault with the given [masterPassword] and for the currently active
* user.

View file

@ -424,6 +424,25 @@ class VaultRepositoryImpl(
mutableTotpCodeResultFlow.tryEmit(totpCodeResult)
}
@Suppress("ReturnCount")
override suspend fun unlockVaultWithBiometrics(): VaultUnlockResult {
val userId = activeUserId ?: return VaultUnlockResult.InvalidStateError
val biometricsKey = authDiskSource
.getUserBiometricUnlockKey(userId = userId)
?: return VaultUnlockResult.InvalidStateError
return unlockVaultForUser(
userId = userId,
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = biometricsKey,
),
)
.also {
if (it is VaultUnlockResult.Success) {
deriveTemporaryPinProtectedUserKeyIfNecessary(userId = userId)
}
}
}
@Suppress("ReturnCount")
override suspend fun unlockVaultWithMasterPassword(
masterPassword: String,

View file

@ -238,6 +238,13 @@ class FakeAuthDiskSource : AuthDiskSource {
assertEquals(organizationKeys, storedOrganizationKeys[userId])
}
/**
* Assert that the [biometricsKey] was stored successfully using the [userId].
*/
fun assertBiometricsKey(userId: String, biometricsKey: String?) {
assertEquals(biometricsKey, storedBiometricKeys[userId])
}
/**
* Assert that the [passwordHash] was stored successfully using the [userId].
*/

View file

@ -10,9 +10,11 @@ import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
@ -653,6 +655,70 @@ class SettingsRepositoryTest {
assertEquals(true, fakeSettingsDiskSource.getPullToRefreshEnabled(userId = userId))
}
@Test
fun `clearBiometricsKey should remove the stored biometrics key`() {
val userId = MOCK_USER_STATE.activeUserId
fakeAuthDiskSource.userState = MOCK_USER_STATE
settingsRepository.clearBiometricsKey()
fakeAuthDiskSource.assertBiometricsKey(
userId = userId,
biometricsKey = null,
)
}
@Test
fun `setupBiometricsKey with missing user state should return BiometricsKeyResult Error`() =
runTest {
fakeAuthDiskSource.userState = null
val result = settingsRepository.setupBiometricsKey()
assertEquals(BiometricsKeyResult.Error, result)
coVerify(exactly = 0) {
vaultSdkSource.getUserEncryptionKey(userId = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `setupBiometricsKey with getUserEncryptionKey failure should return BiometricsKeyResult Error`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = MOCK_USER_STATE.activeUserId
coEvery {
vaultSdkSource.getUserEncryptionKey(userId = userId)
} returns Throwable("Fail").asFailure()
val result = settingsRepository.setupBiometricsKey()
assertEquals(BiometricsKeyResult.Error, result)
coVerify(exactly = 1) {
vaultSdkSource.getUserEncryptionKey(userId = userId)
}
}
@Suppress("MaxLineLength")
@Test
fun `setupBiometricsKey with getUserEncryptionKey success should return BiometricsKeyResult Success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = MOCK_USER_STATE.activeUserId
val encryptedKey = "asdf1234"
coEvery {
vaultSdkSource.getUserEncryptionKey(userId = userId)
} returns encryptedKey.asSuccess()
val result = settingsRepository.setupBiometricsKey()
assertEquals(BiometricsKeyResult.Success, result)
fakeAuthDiskSource.assertBiometricsKey(userId = userId, biometricsKey = encryptedKey)
coVerify(exactly = 1) {
vaultSdkSource.getUserEncryptionKey(userId = userId)
}
}
@Suppress("MaxLineLength")
@Test
fun `storeUnlockPin when the master password on restart is required should only save an encrypted PIN to disk`() {

View file

@ -669,7 +669,171 @@ class VaultRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithMasterPassword with missing user state should return InvalidStateError `() =
fun `unlockVaultWithBiometrics with missing user state should return InvalidStateError`() =
runTest {
fakeAuthDiskSource.userState = null
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
unlockingVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
val result = vaultRepository.unlockVaultWithBiometrics()
assertEquals(VaultUnlockResult.InvalidStateError, result)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
unlockingVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with missing biometrics key should return InvalidStateError`() =
runTest {
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
unlockingVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = MOCK_USER_STATE.activeUserId
fakeAuthDiskSource.storeUserBiometricUnlockKey(userId = userId, biometricsKey = null)
val result = vaultRepository.unlockVaultWithBiometrics()
assertEquals(VaultUnlockResult.InvalidStateError, result)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
unlockingVaultUserIds = emptySet(),
),
vaultRepository.vaultStateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with VaultLockManager Success and no encrypted PIN should unlock for the current user and return Success`() =
runTest {
val userId = MOCK_USER_STATE.activeUserId
val privateKey = "mockPrivateKey-1"
val biometricsKey = "asdf1234"
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultLockManager.unlockVault(
userId = userId,
kdf = MOCK_PROFILE.toSdkParams(),
email = "email",
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = biometricsKey,
),
organizationKeys = null,
)
} returns VaultUnlockResult.Success
fakeAuthDiskSource.apply {
storeUserBiometricUnlockKey(userId = userId, biometricsKey = biometricsKey)
storePrivateKey(userId = userId, privateKey = privateKey)
}
val result = vaultRepository.unlockVaultWithBiometrics()
assertEquals(VaultUnlockResult.Success, result)
coVerify {
vaultLockManager.unlockVault(
userId = userId,
kdf = MOCK_PROFILE.toSdkParams(),
email = "email",
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = biometricsKey,
),
organizationKeys = null,
)
}
coVerify(exactly = 0) {
vaultSdkSource.derivePinProtectedUserKey(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with VaultLockManager Success and a stored encrypted pin should unlock for the current user, derive a new pin-protected key, and return Success`() =
runTest {
val userId = MOCK_USER_STATE.activeUserId
val encryptedPin = "encryptedPin"
val privateKey = "mockPrivateKey-1"
val pinProtectedUserKey = "pinProtectedUserkey"
val biometricsKey = "asdf1234"
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.derivePinProtectedUserKey(
userId = userId,
encryptedPin = encryptedPin,
)
} returns pinProtectedUserKey.asSuccess()
coEvery {
vaultLockManager.unlockVault(
userId = userId,
kdf = MOCK_PROFILE.toSdkParams(),
email = "email",
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = biometricsKey,
),
organizationKeys = null,
)
} returns VaultUnlockResult.Success
fakeAuthDiskSource.apply {
storeUserBiometricUnlockKey(userId = userId, biometricsKey = biometricsKey)
storePrivateKey(userId = userId, privateKey = privateKey)
storeEncryptedPin(userId = userId, encryptedPin = encryptedPin)
storePinProtectedUserKey(
userId = userId,
pinProtectedUserKey = null,
inMemoryOnly = true,
)
}
val result = vaultRepository.unlockVaultWithBiometrics()
assertEquals(VaultUnlockResult.Success, result)
fakeAuthDiskSource.assertPinProtectedUserKey(
userId = userId,
pinProtectedUserKey = pinProtectedUserKey,
inMemoryOnly = true,
)
coVerify {
vaultLockManager.unlockVault(
userId = userId,
kdf = MOCK_PROFILE.toSdkParams(),
email = "email",
privateKey = "mockPrivateKey-1",
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = biometricsKey,
),
organizationKeys = null,
)
}
coEvery {
vaultSdkSource.derivePinProtectedUserKey(
userId = userId,
encryptedPin = encryptedPin,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithMasterPassword with missing user state should return InvalidStateError`() =
runTest {
fakeAuthDiskSource.userState = null
assertEquals(
@ -697,7 +861,7 @@ class VaultRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithMasterPassword with missing user key should return InvalidStateError `() =
fun `unlockVaultWithMasterPassword with missing user key should return InvalidStateError`() =
runTest {
assertEquals(
VaultState(
@ -732,7 +896,7 @@ class VaultRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithMasterPassword with missing private key should return InvalidStateError `() =
fun `unlockVaultWithMasterPassword with missing private key should return InvalidStateError`() =
runTest {
assertEquals(
VaultState(
@ -907,7 +1071,7 @@ class VaultRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithPin with missing user state should return InvalidStateError `() =
fun `unlockVaultWithPin with missing user state should return InvalidStateError`() =
runTest {
fakeAuthDiskSource.userState = null
assertEquals(
@ -935,7 +1099,7 @@ class VaultRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithPin with missing pin-protected user key should return InvalidStateError `() =
fun `unlockVaultWithPin with missing pin-protected user key should return InvalidStateError`() =
runTest {
assertEquals(
VaultState(
@ -970,7 +1134,7 @@ class VaultRepositoryTest {
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithPin with missing private key should return InvalidStateError `() =
fun `unlockVaultWithPin with missing private key should return InvalidStateError`() =
runTest {
assertEquals(
VaultState(