diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index 9baa428c9..1140b5fc9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -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. * diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index 130281320..b8d38d718 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/BiometricsKeyResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/BiometricsKeyResult.kt new file mode 100644 index 000000000..029b8a7ac --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/BiometricsKeyResult.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 5deada358..210a5474c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -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. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 20060867d..836ef82bc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -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, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index ead33d355..f19203667 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -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]. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index 62c02ddef..e13ef613c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -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`() { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 304f686f6..fb560b53c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -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(