From afeeb494da24217abf0c0e69b09e0bc110daa08f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Mon, 20 Oct 2025 18:31:12 +0100 Subject: [PATCH] [PM-23290] Migrate PIN unlock keys to PinProtectedUserKeyEnvelope (#6024) --- .../auth/datasource/disk/AuthDiskSource.kt | 34 +++++ .../datasource/disk/AuthDiskSourceImpl.kt | 47 ++++++ .../auth/manager/UserLogoutManagerImpl.kt | 7 +- .../data/auth/manager/UserStateManagerImpl.kt | 2 +- .../auth/repository/AuthRepositoryImpl.kt | 6 +- .../repository/SettingsRepositoryImpl.kt | 29 +++- .../vault/datasource/sdk/VaultSdkSource.kt | 24 ++- .../datasource/sdk/VaultSdkSourceImpl.kt | 14 +- .../vault/manager/VaultLockManagerImpl.kt | 34 +++++ .../vault/repository/VaultRepositoryImpl.kt | 61 ++++++-- .../datasource/disk/AuthDiskSourceTest.kt | 58 +++++++ .../disk/util/FakeAuthDiskSource.kt | 55 +++++++ .../auth/manager/UserLogoutManagerTest.kt | 30 ++-- .../data/auth/manager/UserStateManagerTest.kt | 16 +- .../auth/repository/AuthRepositoryTest.kt | 12 +- .../repository/SettingsRepositoryTest.kt | 58 +++---- .../datasource/sdk/VaultSdkSourceTest.kt | 22 +-- .../vault/manager/VaultLockManagerTest.kt | 129 ++++++++++++++++ .../vault/repository/VaultRepositoryTest.kt | 144 +++++++++++++++--- 19 files changed, 645 insertions(+), 137 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index ef19e6a847..f38336bac6 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -216,25 +216,59 @@ interface AuthDiskSource : AppIdProvider { /** * Retrieves a pin-protected user key for the given [userId]. */ + @Deprecated( + message = "Use getPinProtectedUserKeyEnvelope instead.", + replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelope"), + ) fun getPinProtectedUserKey(userId: String): String? + /** + * Retrieves a pin-protected user key envelope for the given [userId]. + */ + fun getPinProtectedUserKeyEnvelope(userId: String): String? + /** * Stores a pin-protected user key for the given [userId]. * * When [inMemoryOnly] is `true`, the value will only be available via a call to * [getPinProtectedUserKey] during the current app session. */ + @Deprecated( + message = "Use storePinProtectedUserKeyEnvelope instead.", + replaceWith = ReplaceWith("storePinProtectedUserKeyEnvelope"), + ) fun storePinProtectedUserKey( userId: String, pinProtectedUserKey: String?, inMemoryOnly: Boolean = false, ) + /** + * Stores a pin-protected user key envelope for the given [userId]. + * + * When [inMemoryOnly] is `true`, the value will only be available via a call to + * [getPinProtectedUserKeyEnvelope] during the current app session. + */ + fun storePinProtectedUserKeyEnvelope( + userId: String, + pinProtectedUserKeyEnvelope: String?, + inMemoryOnly: Boolean = false, + ) + /** * Retrieves a flow for the pin-protected user key for the given [userId]. */ + @Deprecated( + message = "Use getPinProtectedUserKeyEnvelopeFlow instead.", + replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelopeFlow"), + ) fun getPinProtectedUserKeyFlow(userId: String): Flow + /** + * Retrieves a flow for the pin-protected user key envelope for the given [userId]. + */ + fun getPinProtectedUserKeyEnvelopeFlow(userId: String): Flow + /** * Gets a two-factor auth token using a user's [email]. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index 68d4032e48..d994bcd26a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -37,6 +37,7 @@ private const val INVALID_UNLOCK_ATTEMPTS_KEY = "invalidUnlockAttempts" private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey" private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey" private const val PIN_PROTECTED_USER_KEY_KEY = "pinKeyEncryptedUserKey" +private const val PIN_PROTECTED_USER_KEY_KEY_ENVELOPE = "pinKeyEncryptedUserKeyEnvelope" private const val ENCRYPTED_PIN_KEY = "protectedPin" private const val ORGANIZATIONS_KEY = "organizations" private const val ORGANIZATION_KEYS_KEY = "encOrgKeys" @@ -67,6 +68,7 @@ class AuthDiskSourceImpl( AuthDiskSource { private val inMemoryPinProtectedUserKeys = mutableMapOf() + private val inMemoryPinProtectedUserKeyEnvelopes = mutableMapOf() private val mutableShouldUseKeyConnectorFlowMap = mutableMapOf>() private val mutableOrganizationsFlowMap = @@ -82,6 +84,8 @@ class AuthDiskSourceImpl( mutableMapOf>() private val mutablePinProtectedUserKeyFlowMap = mutableMapOf>() + private val mutablePinProtectedUserKeyEnvelopeFlowMap = + mutableMapOf>() private val mutableUserStateFlow = bufferedMutableSharedFlow(replay = 1) override var userState: UserStateJson? @@ -142,6 +146,7 @@ class AuthDiskSourceImpl( storeUserKey(userId = userId, userKey = null) storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null) storePinProtectedUserKey(userId = userId, pinProtectedUserKey = null) + storePinProtectedUserKeyEnvelope(userId = userId, pinProtectedUserKeyEnvelope = null) storeEncryptedPin(userId = userId, encryptedPin = null) storePrivateKey(userId = userId, privateKey = null) storeAccountKeys(userId = userId, accountKeys = null) @@ -329,10 +334,24 @@ class AuthDiskSourceImpl( getMutableBiometricUnlockKeyFlow(userId) .onSubscription { emit(getUserBiometricUnlockKey(userId = userId)) } + @Deprecated( + "Use getPinProtectedUserKeyEnvelope instead.", + replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelope"), + ) override fun getPinProtectedUserKey(userId: String): String? = inMemoryPinProtectedUserKeys[userId] ?: getString(key = PIN_PROTECTED_USER_KEY_KEY.appendIdentifier(userId)) + override fun getPinProtectedUserKeyEnvelope(userId: String): String? = + inMemoryPinProtectedUserKeyEnvelopes[userId] + ?: getString( + key = PIN_PROTECTED_USER_KEY_KEY_ENVELOPE.appendIdentifier(userId), + ) + + @Deprecated( + "Use storePinProtectedUserKeyEnvelope instead.", + replaceWith = ReplaceWith("storePinProtectedUserKeyEnvelope"), + ) override fun storePinProtectedUserKey( userId: String, pinProtectedUserKey: String?, @@ -347,10 +366,32 @@ class AuthDiskSourceImpl( getMutablePinProtectedUserKeyFlow(userId).tryEmit(pinProtectedUserKey) } + override fun storePinProtectedUserKeyEnvelope( + userId: String, + pinProtectedUserKeyEnvelope: String?, + inMemoryOnly: Boolean, + ) { + inMemoryPinProtectedUserKeyEnvelopes[userId] = pinProtectedUserKeyEnvelope + if (inMemoryOnly) return + putString( + key = PIN_PROTECTED_USER_KEY_KEY_ENVELOPE.appendIdentifier(userId), + value = pinProtectedUserKeyEnvelope, + ) + getMutablePinProtectedUserKeyEnvelopeFlow(userId).tryEmit(pinProtectedUserKeyEnvelope) + } + + @Deprecated( + "Use getPinProtectedUserKeyEnvelopeFlow instead.", + replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelopeFlow"), + ) override fun getPinProtectedUserKeyFlow(userId: String): Flow = getMutablePinProtectedUserKeyFlow(userId) .onSubscription { emit(getPinProtectedUserKey(userId = userId)) } + override fun getPinProtectedUserKeyEnvelopeFlow(userId: String): Flow = + getMutablePinProtectedUserKeyEnvelopeFlow(userId) + .onSubscription { emit(getPinProtectedUserKeyEnvelope(userId = userId)) } + override fun getTwoFactorToken(email: String): String? = getString(key = TWO_FACTOR_TOKEN_KEY.appendIdentifier(email)) @@ -579,6 +620,12 @@ class AuthDiskSourceImpl( bufferedMutableSharedFlow(replay = 1) } + private fun getMutablePinProtectedUserKeyEnvelopeFlow( + userId: String, + ): MutableSharedFlow = mutablePinProtectedUserKeyEnvelopeFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun migrateAccountTokens() { userState ?.accounts diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt index b4c6d842eb..5fb643dfa6 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerImpl.kt @@ -85,7 +85,8 @@ class UserLogoutManagerImpl( // Save any data that will still need to be retained after otherwise clearing all dat val vaultTimeoutInMinutes = settingsDiskSource.getVaultTimeoutInMinutes(userId = userId) val vaultTimeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId) - val pinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId) + val pinProtectedUserKeyEnvelope = authDiskSource + .getPinProtectedUserKeyEnvelope(userId = userId) switchUserIfAvailable( currentUserId = userId, @@ -107,9 +108,9 @@ class UserLogoutManagerImpl( vaultTimeoutAction = vaultTimeoutAction, ) } - authDiskSource.storePinProtectedUserKey( + authDiskSource.storePinProtectedUserKeyEnvelope( userId = userId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserStateManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserStateManagerImpl.kt index a99cde5083..df596a3b80 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserStateManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/UserStateManagerImpl.kt @@ -156,7 +156,7 @@ class UserStateManagerImpl( private fun getVaultUnlockType( userId: String, ): VaultUnlockType = authDiskSource - .getPinProtectedUserKey(userId = userId) + .getPinProtectedUserKeyEnvelope(userId = userId) ?.let { VaultUnlockType.PIN } ?: VaultUnlockType.MASTER_PASSWORD } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 317034d6f9..2da550cc45 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -1295,8 +1295,8 @@ class AuthRepositoryImpl( ?.activeAccount ?.profile ?: return ValidatePinResult.Error(error = NoActiveUserException()) - val pinProtectedUserKey = authDiskSource - .getPinProtectedUserKey(userId = activeAccount.userId) + val pinProtectedUserKeyEnvelope = authDiskSource + .getPinProtectedUserKeyEnvelope(userId = activeAccount.userId) ?: return ValidatePinResult.Error( error = MissingPropertyException("Pin Protected User Key"), ) @@ -1304,7 +1304,7 @@ class AuthRepositoryImpl( .validatePin( userId = activeAccount.userId, pin = pin, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKey = pinProtectedUserKeyEnvelope, ) .fold( onSuccess = { ValidatePinResult.Success(isValid = it) }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index 74efac3ecb..daf4941478 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -293,7 +294,11 @@ class SettingsRepositoryImpl( ?.let { userId -> authDiskSource .getPinProtectedUserKeyFlow(userId) - .map { it != null } + .combine( + authDiskSource.getPinProtectedUserKeyEnvelopeFlow(userId), + ) { pinProtectedUserKey, pinProtectedUserKeyEnvelope -> + pinProtectedUserKey != null || pinProtectedUserKeyEnvelope != null + } } ?: flowOf(false) @@ -403,7 +408,7 @@ class SettingsRepositoryImpl( ?.userDecryptionOptions ?.hasMasterPassword != false val timeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId) - val hasPin = authDiskSource.getPinProtectedUserKey(userId = userId) != null + val hasPin = authDiskSource.getPinProtectedUserKeyEnvelope(userId = userId) != null val hasBiometrics = authDiskSource.getUserBiometricUnlockKey(userId = userId) != null // The timeout action cannot be "lock" if you do not have master password, pin, or // biometrics unlock enabled. @@ -527,21 +532,27 @@ class SettingsRepositoryImpl( val userId = activeUserId ?: return unconfinedScope.launch { vaultSdkSource - .derivePinKey( + .enrollPin( userId = userId, pin = pin, ) .fold( - onSuccess = { derivePinKeyResponse -> + onSuccess = { enrollPinResponse -> authDiskSource.apply { storeEncryptedPin( userId = userId, - encryptedPin = derivePinKeyResponse.encryptedPin, + encryptedPin = enrollPinResponse.userKeyEncryptedPin, ) + storePinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = + enrollPinResponse.pinProtectedUserKeyEnvelope, + inMemoryOnly = shouldRequireMasterPasswordOnRestart, + ) + // Remove any legacy pin protected user keys. storePinProtectedUserKey( userId = userId, - pinProtectedUserKey = derivePinKeyResponse.pinProtectedUserKey, - inMemoryOnly = shouldRequireMasterPasswordOnRestart, + pinProtectedUserKey = null, ) } }, @@ -561,6 +572,10 @@ class SettingsRepositoryImpl( userId = userId, encryptedPin = null, ) + authDiskSource.storePinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = null, + ) authDiskSource.storePinProtectedUserKey( userId = userId, pinProtectedUserKey = null, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index b8e796185a..d061d7e71a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -2,7 +2,7 @@ package com.x8bit.bitwarden.data.vault.datasource.sdk import com.bitwarden.collections.Collection import com.bitwarden.collections.CollectionView -import com.bitwarden.core.DerivePinKeyResponse +import com.bitwarden.core.EnrollPinResponse import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.InitUserCryptoRequest @@ -75,30 +75,26 @@ interface VaultSdkSource { ): Result /** - * Derives a "pin key" from the given [pin] for the given [userId]. This can be used to later - * unlock their vault via a call to [initializeCrypto] with [InitUserCryptoMethod.Pin]. + * Protects the current user key with the provided PIN. This can be used to later unlock + * their vault via a call to [initializeCrypto] with [InitUserCryptoMethod.PinEnvelope]. * * This should only be called after a successful call to [initializeCrypto] for the associated * user. */ - suspend fun derivePinKey( + suspend fun enrollPin( userId: String, pin: String, - ): Result + ): Result /** - * Derives a pin-protected user key from the given [encryptedPin] for the given [userId]. This - * value must be derived from a previous call to [derivePinKey] with a plaintext PIN. This can - * be used to later unlock their vault via a call to [initializeCrypto] with - * [InitUserCryptoMethod.Pin]. - * - * This should only be called after a successful call to [initializeCrypto] for the associated - * user. + * Protects the current user key with the provided PIN. The result can be stored and later + * used to initialize another client instance by using the PIN and the PIN key with + * [initializeCrypto]. The provided pin is encrypted with the user key. */ - suspend fun derivePinProtectedUserKey( + suspend fun enrollPinWithEncryptedPin( userId: String, encryptedPin: String, - ): Result + ): Result /** * Validate the user pin using the [pinProtectedUserKey]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index d24be33fdc..8e9abad95b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -4,7 +4,7 @@ import com.bitwarden.collections.Collection import com.bitwarden.collections.CollectionView import com.bitwarden.core.DeriveKeyConnectorException import com.bitwarden.core.DeriveKeyConnectorRequest -import com.bitwarden.core.DerivePinKeyResponse +import com.bitwarden.core.EnrollPinResponse import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoRequest import com.bitwarden.core.UpdateKdfResponse @@ -109,24 +109,24 @@ class VaultSdkSourceImpl( } } - override suspend fun derivePinKey( + override suspend fun enrollPin( userId: String, pin: String, - ): Result = + ): Result = runCatchingWithLogs { getClient(userId = userId) .crypto() - .derivePinKey(pin = pin) + .enrollPin(pin = pin) } - override suspend fun derivePinProtectedUserKey( + override suspend fun enrollPinWithEncryptedPin( userId: String, encryptedPin: String, - ): Result = + ): Result = runCatchingWithLogs { getClient(userId = userId) .crypto() - .derivePinUserKey(encryptedPin = encryptedPin) + .enrollPinWithEncryptedPin(encryptedPin = encryptedPin) } override suspend fun validatePin( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt index 4a5beb912d..13b4e62e56 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt @@ -239,6 +239,7 @@ class VaultLockManagerImpl( trustedDeviceManager .trustThisDeviceIfNecessary(userId = userId) updateKdfIfNeeded(initUserCryptoMethod) + migratePinProtectedUserKeyIfNeeded(userId = userId) setVaultToUnlocked(userId = userId) } else { incrementInvalidUnlockCount(userId = userId) @@ -275,6 +276,39 @@ class VaultLockManagerImpl( ) } + /** + * Migrates the PIN-protected user key for the given user if needed. + * + * If an encrypted PIN exists and no PIN-protected user key envelope is present, + * enrolls the PIN with the encrypted PIN and stores the resulting envelope. + * Optionally marks the envelope as in-memory only if the PIN-protected user key is not present. + * + * @param userId The ID of the user for whom to migrate the PIN-protected user key. + */ + private suspend fun migratePinProtectedUserKeyIfNeeded(userId: String) { + val encryptedPin = authDiskSource.getEncryptedPin(userId) ?: return + if (authDiskSource.getPinProtectedUserKeyEnvelope(userId) != null) return + + val inMemoryOnly = authDiskSource.getPinProtectedUserKey(userId) == null + vaultSdkSource.enrollPinWithEncryptedPin(userId, encryptedPin) + .onSuccess { enrollPinResponse -> + authDiskSource.storeEncryptedPin( + userId = userId, + encryptedPin = enrollPinResponse.userKeyEncryptedPin, + ) + authDiskSource.storePinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = enrollPinResponse.pinProtectedUserKeyEnvelope, + inMemoryOnly = inMemoryOnly, + ) + authDiskSource.storePinProtectedUserKey( + userId = userId, + pinProtectedUserKey = null, + inMemoryOnly = inMemoryOnly, + ) + } + } + /** * Increments the stored invalid unlock count for the given [userId] and automatically logs out * if this new value is greater than [MAXIMUM_INVALID_UNLOCK_ATTEMPTS]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 2486424a96..db5bc2977e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -360,17 +360,34 @@ class VaultRepositoryImpl( ): VaultUnlockResult { val userId = activeUserId ?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException()) - val pinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId) - ?: return VaultUnlockResult.InvalidStateError( - error = MissingPropertyException("Pin protected key"), - ) - return this.unlockVaultForUser( - userId = userId, - initUserCryptoMethod = InitUserCryptoMethod.Pin( - pin = pin, - pinProtectedUserKey = pinProtectedUserKey, - ), - ) + + return authDiskSource.getPinProtectedUserKeyEnvelope(userId = userId) + ?.let { pinProtectedUserKeyEnvelope -> + this.unlockVaultForUser( + userId = userId, + initUserCryptoMethod = InitUserCryptoMethod.PinEnvelope( + pin = pin, + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, + ), + ) + } + ?: run { + // This is needed to support unlocking with a legacy pin protected user key. + // Once the vault is unlocked, the user's pin protected user key is migrated to + // a pin protected user key envelope. + val pinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId) + ?: return VaultUnlockResult.InvalidStateError( + error = MissingPropertyException("Pin protected key"), + ) + + this.unlockVaultForUser( + userId = userId, + initUserCryptoMethod = InitUserCryptoMethod.Pin( + pin = pin, + pinProtectedUserKey = pinProtectedUserKey, + ), + ) + } } override suspend fun generateTotp( @@ -502,17 +519,29 @@ class VaultRepositoryImpl( */ private suspend fun deriveTemporaryPinProtectedUserKeyIfNecessary(userId: String) { val encryptedPin = authDiskSource.getEncryptedPin(userId = userId) ?: return - val existingPinProtectedUserKey = authDiskSource.getPinProtectedUserKey(userId = userId) - if (existingPinProtectedUserKey != null) return + val existingPinProtectedUserKeyEnvelope = authDiskSource + .getPinProtectedUserKeyEnvelope( + userId = userId, + ) + if (existingPinProtectedUserKeyEnvelope != null) return vaultSdkSource - .derivePinProtectedUserKey( + .enrollPinWithEncryptedPin( userId = userId, encryptedPin = encryptedPin, ) - .onSuccess { pinProtectedUserKey -> + .onSuccess { enrollPinResponse -> + authDiskSource.storeEncryptedPin( + userId = userId, + encryptedPin = enrollPinResponse.userKeyEncryptedPin, + ) + authDiskSource.storePinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = enrollPinResponse.pinProtectedUserKeyEnvelope, + inMemoryOnly = true, + ) authDiskSource.storePinProtectedUserKey( userId = userId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKey = null, inMemoryOnly = true, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index 372d1afac8..ab5b1db1fa 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -331,6 +331,7 @@ class AuthDiskSourceTest { assertNull(authDiskSource.getUserBiometricInitVector(userId = userId)) assertNull(authDiskSource.getUserBiometricUnlockKey(userId = userId)) assertNull(authDiskSource.getPinProtectedUserKey(userId = userId)) + assertNull(authDiskSource.getPinProtectedUserKeyEnvelope(userId = userId)) assertNull(authDiskSource.getInvalidUnlockAttempts(userId = userId)) assertNull(authDiskSource.getUserKey(userId = userId)) assertNull(authDiskSource.getUserAutoUnlockKey(userId = userId)) @@ -825,6 +826,63 @@ class AuthDiskSourceTest { ) } + @Test + @Suppress("MaxLineLength") + fun `storePinProtectedUserKeyEnvelope should update result flow from getPinProtectedUserKeyEnvelopeFlow`() = + runTest { + val topSecretKey = "topsecret" + val mockUserId = "mockUserId" + authDiskSource.getPinProtectedUserKeyEnvelopeFlow(mockUserId).test { + assertNull(awaitItem()) + authDiskSource.storePinProtectedUserKeyEnvelope( + userId = mockUserId, + pinProtectedUserKeyEnvelope = topSecretKey, + ) + assertEquals(topSecretKey, awaitItem()) + } + } + + @Test + fun `getPinProtectedUserKeyEnvelope should pull from SharedPreferences`() { + val pinProtectedUserKeyEnvelopeBaseKey = + "bwPreferencesStorage:pinKeyEncryptedUserKeyEnvelope" + val mockUserId = "mockUserId" + val mockPinProtectedUserKeyEnvelope = "mockPinProtectedUserKeyEnvelope" + fakeSharedPreferences + .edit { + putString( + "${pinProtectedUserKeyEnvelopeBaseKey}_$mockUserId", + mockPinProtectedUserKeyEnvelope, + ) + } + val actual = authDiskSource.getPinProtectedUserKeyEnvelope(userId = mockUserId) + assertEquals( + mockPinProtectedUserKeyEnvelope, + actual, + ) + } + + @Test + fun `storePinProtectedUserKeyEnvelope should pull from SharedPreferences`() { + val pinProtectedUserKeyEnvelopeBaseKey = + "bwPreferencesStorage:pinKeyEncryptedUserKeyEnvelope" + val mockUserId = "mockUserId" + val mockPinProtectedUserKeyEnvelope = "mockPinProtectedUserKeyEnvelope" + authDiskSource.storePinProtectedUserKeyEnvelope( + userId = mockUserId, + pinProtectedUserKeyEnvelope = mockPinProtectedUserKeyEnvelope, + ) + val actual = fakeSharedPreferences + .getString( + "${pinProtectedUserKeyEnvelopeBaseKey}_$mockUserId", + null, + ) + assertEquals( + mockPinProtectedUserKeyEnvelope, + actual, + ) + } + @Test fun `storePinProtectedUserKey should update result flow from getPinProtectedUserKeyFlow`() = runTest { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 4bdb876e50..43a6c43146 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -65,6 +65,9 @@ class FakeAuthDiskSource : AuthDiskSource { private val storedShowImportLogins = mutableMapOf() private val storedLastLockTimestampState = mutableMapOf() private val storedAccountKeys = mutableMapOf() + private val storedPinProtectedUserKeyEnvelopes = mutableMapOf>() + private val mutablePinProtectedUserKeyEnvelopesFlowMap = + mutableMapOf>() override var userState: UserStateJson? = null set(value) { @@ -89,11 +92,19 @@ class FakeAuthDiskSource : AuthDiskSource { storedBiometricInitVectors.remove(userId) storedBiometricKeys.remove(userId) storedOrganizationKeys.remove(userId) + storedPinProtectedUserKeyEnvelopes.remove(userId) mutableShouldUseKeyConnectorFlowMap.remove(userId) mutableOrganizationsFlowMap.remove(userId) mutablePoliciesFlowMap.remove(userId) mutableAccountTokensFlowMap.remove(userId) + mutablePinProtectedUserKeyEnvelopesFlowMap.remove(userId) + } + + private fun getMutablePinProtectedUserKeyEnvelopeFlow( + userId: String, + ): MutableSharedFlow = mutablePinProtectedUserKeyEnvelopesFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) } override fun getShouldUseKeyConnectorFlow( @@ -170,9 +181,17 @@ class FakeAuthDiskSource : AuthDiskSource { storedUserAutoUnlockKeys[userId] = userAutoUnlockKey } + @Deprecated( + "Use getPinProtectedUserKeyEnvelope instead.", + replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelope"), + ) override fun getPinProtectedUserKey(userId: String): String? = storedPinProtectedUserKeys[userId]?.first + @Deprecated( + "Use storePinProtectedUserKeyEnvelope instead.", + replaceWith = ReplaceWith("storePinProtectedUserKeyEnvelope"), + ) override fun storePinProtectedUserKey( userId: String, pinProtectedUserKey: String?, @@ -182,6 +201,10 @@ class FakeAuthDiskSource : AuthDiskSource { getMutablePinProtectedUserKeyFlow(userId).tryEmit(pinProtectedUserKey) } + @Deprecated( + "Use getPinProtectedUserKeyEnvelopeFlow instead.", + replaceWith = ReplaceWith("getPinProtectedUserKeyEnvelopeFlow"), + ) override fun getPinProtectedUserKeyFlow(userId: String): Flow = getMutablePinProtectedUserKeyFlow(userId) .onSubscription { @@ -331,6 +354,24 @@ class FakeAuthDiskSource : AuthDiskSource { storedLastLockTimestampState[userId] = lastLockTimestamp } + override fun getPinProtectedUserKeyEnvelope(userId: String): String? = + storedPinProtectedUserKeyEnvelopes[userId]?.first + + override fun storePinProtectedUserKeyEnvelope( + userId: String, + pinProtectedUserKeyEnvelope: String?, + inMemoryOnly: Boolean, + ) { + storedPinProtectedUserKeyEnvelopes[userId] = pinProtectedUserKeyEnvelope to inMemoryOnly + getMutablePinProtectedUserKeyEnvelopeFlow(userId).tryEmit(pinProtectedUserKeyEnvelope) + } + + override fun getPinProtectedUserKeyEnvelopeFlow(userId: String): Flow = + getMutablePinProtectedUserKeyEnvelopeFlow(userId) + .onSubscription { + emit(getPinProtectedUserKeyEnvelope(userId)) + } + /** * Assert the the [isTdeLoginComplete] was stored successfully using the [userId]. */ @@ -427,6 +468,20 @@ class FakeAuthDiskSource : AuthDiskSource { assertEquals(pinProtectedUserKey to inMemoryOnly, storedPinProtectedUserKeys[userId]) } + /** + * Assert that the [pinProtectedUserKeyEnvelope] was stored successfully using the [userId]. + */ + fun assertPinProtectedUserKeyEnvelope( + userId: String, + pinProtectedUserKeyEnvelope: String?, + inMemoryOnly: Boolean = false, + ) { + assertEquals( + pinProtectedUserKeyEnvelope to inMemoryOnly, + storedPinProtectedUserKeyEnvelopes[userId], + ) + } + /** * Assert the the [organizationKeys] was stored successfully using the [userId]. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt index bced19e086..7899c7f7ba 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserLogoutManagerTest.kt @@ -34,7 +34,12 @@ import java.time.ZonedDateTime class UserLogoutManagerTest { private val authDiskSource: AuthDiskSource = mockk { every { storeAccountTokens(userId = any(), accountTokens = null) } just runs - every { storePinProtectedUserKey(userId = any(), pinProtectedUserKey = any()) } just runs + every { + storePinProtectedUserKeyEnvelope( + userId = any(), + pinProtectedUserKeyEnvelope = any(), + ) + } just runs every { userState = any() } just runs every { clearData(any()) } just runs } @@ -137,8 +142,9 @@ class UserLogoutManagerTest { every { settingsDiskSource.getVaultTimeoutAction(userId = userId) } returns vaultTimeoutAction + every { - authDiskSource.getPinProtectedUserKey(userId = userId) + authDiskSource.getPinProtectedUserKeyEnvelope(userId = userId) } returns pinProtectedUserKey userLogoutManager.softLogout(userId = userId, reason = LogoutReason.Timeout) @@ -164,9 +170,9 @@ class UserLogoutManagerTest { userId = userId, vaultTimeoutAction = vaultTimeoutAction, ) - authDiskSource.storePinProtectedUserKey( + authDiskSource.storePinProtectedUserKeyEnvelope( userId = userId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKeyEnvelope = pinProtectedUserKey, ) } } @@ -186,7 +192,7 @@ class UserLogoutManagerTest { settingsDiskSource.getVaultTimeoutAction(userId = userId) } returns vaultTimeoutAction every { - authDiskSource.getPinProtectedUserKey(userId = userId) + authDiskSource.getPinProtectedUserKeyEnvelope(userId = userId) } returns pinProtectedUserKey userLogoutManager.softLogout(userId = userId, reason = LogoutReason.Timeout) @@ -206,9 +212,9 @@ class UserLogoutManagerTest { userId = userId, vaultTimeoutAction = vaultTimeoutAction, ) - authDiskSource.storePinProtectedUserKey( + authDiskSource.storePinProtectedUserKeyEnvelope( userId = userId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKeyEnvelope = pinProtectedUserKey, ) } } @@ -222,15 +228,15 @@ class UserLogoutManagerTest { val pinProtectedUserKey = "pinProtectedUserKey" every { authDiskSource.userState } returns MULTI_USER_STATE + every { + authDiskSource.getPinProtectedUserKeyEnvelope(userId) + } returns pinProtectedUserKey every { settingsDiskSource.getVaultTimeoutInMinutes(userId = userId) } returns vaultTimeoutInMinutes every { settingsDiskSource.getVaultTimeoutAction(userId = userId) } returns vaultTimeoutAction - every { - authDiskSource.getPinProtectedUserKey(userId = userId) - } returns pinProtectedUserKey userLogoutManager.softLogout(userId = userId, reason = LogoutReason.SecurityStamp) @@ -249,9 +255,9 @@ class UserLogoutManagerTest { userId = userId, vaultTimeoutAction = vaultTimeoutAction, ) - authDiskSource.storePinProtectedUserKey( + authDiskSource.storePinProtectedUserKeyEnvelope( userId = userId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKeyEnvelope = pinProtectedUserKey, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserStateManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserStateManagerTest.kt index e858a81a8c..db7db615e7 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserStateManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/UserStateManagerTest.kt @@ -92,13 +92,13 @@ class UserStateManagerTest { ) fakeAuthDiskSource.apply { - storePinProtectedUserKey( + storePinProtectedUserKeyEnvelope( userId = USER_ID_1, - pinProtectedUserKey = "pinProtectedUseKey", + pinProtectedUserKeyEnvelope = "pinProtectedUseKey", ) - storePinProtectedUserKey( + storePinProtectedUserKeyEnvelope( userId = USER_ID_2, - pinProtectedUserKey = "pinProtectedUseKey", + pinProtectedUserKeyEnvelope = "pinProtectedUseKey", ) userState = MULTI_USER_STATE } @@ -137,13 +137,13 @@ class UserStateManagerTest { ) fakeAuthDiskSource.apply { - storePinProtectedUserKey( + storePinProtectedUserKeyEnvelope( userId = USER_ID_1, - pinProtectedUserKey = null, + pinProtectedUserKeyEnvelope = null, ) - storePinProtectedUserKey( + storePinProtectedUserKeyEnvelope( userId = USER_ID_2, - pinProtectedUserKey = null, + pinProtectedUserKeyEnvelope = null, ) storeOrganizations( userId = USER_ID_1, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index d08b767846..d19175597d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -6353,9 +6353,9 @@ class AuthRepositoryTest { val pinProtectedUserKey = "pinProtectedUserKey" val error = Throwable("Fail!") fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - fakeAuthDiskSource.storePinProtectedUserKey( + fakeAuthDiskSource.storePinProtectedUserKeyEnvelope( userId = SINGLE_USER_STATE_1.activeUserId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKeyEnvelope = pinProtectedUserKey, ) coEvery { vaultSdkSource.validatePin( @@ -6387,9 +6387,9 @@ class AuthRepositoryTest { val pin = "PIN" val pinProtectedUserKey = "pinProtectedUserKey" fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - fakeAuthDiskSource.storePinProtectedUserKey( + fakeAuthDiskSource.storePinProtectedUserKeyEnvelope( userId = SINGLE_USER_STATE_1.activeUserId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKeyEnvelope = pinProtectedUserKey, ) coEvery { vaultSdkSource.validatePin( @@ -6421,9 +6421,9 @@ class AuthRepositoryTest { val pin = "PIN" val pinProtectedUserKey = "pinProtectedUserKey" fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - fakeAuthDiskSource.storePinProtectedUserKey( + fakeAuthDiskSource.storePinProtectedUserKeyEnvelope( userId = SINGLE_USER_STATE_1.activeUserId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKeyEnvelope = pinProtectedUserKey, ) coEvery { vaultSdkSource.validatePin( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index 7323599a64..483ce3c27f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository import android.view.autofill.AutofillManager import app.cash.turbine.test import com.bitwarden.authenticatorbridge.util.generateSecretKey -import com.bitwarden.core.DerivePinKeyResponse +import com.bitwarden.core.EnrollPinResponse import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess @@ -202,9 +202,9 @@ class SettingsRepositoryTest { // Updating the Vault settings values and calling setDefaultsIfNecessary again has no // effect on the currently stored values since we have a way to unlock the vault. - fakeAuthDiskSource.storePinProtectedUserKey( + fakeAuthDiskSource.storePinProtectedUserKeyEnvelope( userId = USER_ID, - pinProtectedUserKey = "pinProtectedKey", + pinProtectedUserKeyEnvelope = "pinProtectedKey", ) fakeSettingsDiskSource.apply { storeVaultTimeoutInMinutes( @@ -924,19 +924,19 @@ class SettingsRepositoryTest { @Test fun `storeUnlockPin when the master password on restart is required should only save an encrypted PIN to disk`() { val pin = "1234" - val encryptedPin = "encryptedPin" - val pinProtectedUserKey = "pinProtectedUserKey" - val derivePinKeyResponse = DerivePinKeyResponse( - pinProtectedUserKey = pinProtectedUserKey, - encryptedPin = encryptedPin, + val userKeyEncryptedPin = "encryptedPin" + val pinProtectedUserKeyEnvelope = "pinProtectedUserKeyEnvelope" + val enrollResponse = EnrollPinResponse( + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, + userKeyEncryptedPin = userKeyEncryptedPin, ) fakeAuthDiskSource.userState = MOCK_USER_STATE coEvery { - vaultSdkSource.derivePinKey( + vaultSdkSource.enrollPin( userId = USER_ID, pin = pin, ) - } returns derivePinKeyResponse.asSuccess() + } returns enrollResponse.asSuccess() settingsRepository.storeUnlockPin( pin = pin, @@ -946,16 +946,16 @@ class SettingsRepositoryTest { fakeAuthDiskSource.apply { assertEncryptedPin( userId = USER_ID, - encryptedPin = encryptedPin, + encryptedPin = userKeyEncryptedPin, ) - assertPinProtectedUserKey( + assertPinProtectedUserKeyEnvelope( userId = USER_ID, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, inMemoryOnly = true, ) } coVerify { - vaultSdkSource.derivePinKey( + vaultSdkSource.enrollPin( userId = USER_ID, pin = pin, ) @@ -966,19 +966,19 @@ class SettingsRepositoryTest { @Test fun `storeUnlockPin when the master password on restart is not required should save all PIN data to disk`() { val pin = "1234" - val encryptedPin = "encryptedPin" - val pinProtectedUserKey = "pinProtectedUserKey" - val derivePinKeyResponse = DerivePinKeyResponse( - pinProtectedUserKey = pinProtectedUserKey, - encryptedPin = encryptedPin, + val userKeyEncryptedPin = "encryptedPin" + val pinProtectedUserKeyEnvelope = "pinProtectedUserKeyEnvelope" + val enrollResponse = EnrollPinResponse( + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, + userKeyEncryptedPin = userKeyEncryptedPin, ) fakeAuthDiskSource.userState = MOCK_USER_STATE coEvery { - vaultSdkSource.derivePinKey( + vaultSdkSource.enrollPin( userId = USER_ID, pin = pin, ) - } returns derivePinKeyResponse.asSuccess() + } returns enrollResponse.asSuccess() settingsRepository.storeUnlockPin( pin = pin, @@ -988,16 +988,16 @@ class SettingsRepositoryTest { fakeAuthDiskSource.apply { assertEncryptedPin( userId = USER_ID, - encryptedPin = encryptedPin, + encryptedPin = userKeyEncryptedPin, ) - assertPinProtectedUserKey( + assertPinProtectedUserKeyEnvelope( userId = USER_ID, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, inMemoryOnly = false, ) } coVerify { - vaultSdkSource.derivePinKey( + vaultSdkSource.enrollPin( userId = USER_ID, pin = pin, ) @@ -1013,9 +1013,9 @@ class SettingsRepositoryTest { userId = USER_ID, encryptedPin = "encryptedPin", ) - storePinProtectedUserKey( + storePinProtectedUserKeyEnvelope( userId = USER_ID, - pinProtectedUserKey = "pinProtectedUserKey", + pinProtectedUserKeyEnvelope = "pinProtectedUserKeyEnvelope", ) } @@ -1026,9 +1026,9 @@ class SettingsRepositoryTest { userId = USER_ID, encryptedPin = null, ) - assertPinProtectedUserKey( + assertPinProtectedUserKeyEnvelope( userId = USER_ID, - pinProtectedUserKey = null, + pinProtectedUserKeyEnvelope = null, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt index 842f224392..7b16740649 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt @@ -4,7 +4,7 @@ import com.bitwarden.collections.Collection import com.bitwarden.collections.CollectionView import com.bitwarden.core.DeriveKeyConnectorException import com.bitwarden.core.DeriveKeyConnectorRequest -import com.bitwarden.core.DerivePinKeyResponse +import com.bitwarden.core.EnrollPinResponse import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoRequest import com.bitwarden.core.MasterPasswordAuthenticationData @@ -339,14 +339,14 @@ class VaultSdkSourceTest { } @Test - fun `derivePinKey should call SDK and return a Result with the correct data`() = runBlocking { + fun `enrollPin should call SDK and return a Result with the correct data`() = runBlocking { val userId = "userId" val pin = "pin" - val expectedResult = mockk() + val expectedResult = mockk() coEvery { - clientCrypto.derivePinKey(pin = pin) + clientCrypto.enrollPin(pin = pin) } returns expectedResult - val result = vaultSdkSource.derivePinKey( + val result = vaultSdkSource.enrollPin( userId = userId, pin = pin, ) @@ -355,21 +355,21 @@ class VaultSdkSourceTest { result, ) coVerify { - clientCrypto.derivePinKey(pin) + clientCrypto.enrollPin(pin) } coVerify { sdkClientManager.getOrCreateClient(userId = userId) } } @Test - fun `derivePinProtectedUserKey should call SDK and return a Result with the correct data`() = + fun `enrollPinWithEncryptedPin should call SDK and return a Result with the correct data`() = runBlocking { val userId = "userId" val encryptedPin = "encryptedPin" - val expectedResult = "pinProtectedUserKey" + val expectedResult = mockk() coEvery { - clientCrypto.derivePinUserKey(encryptedPin = encryptedPin) + clientCrypto.enrollPinWithEncryptedPin(encryptedPin = encryptedPin) } returns expectedResult - val result = vaultSdkSource.derivePinProtectedUserKey( + val result = vaultSdkSource.enrollPinWithEncryptedPin( userId = userId, encryptedPin = encryptedPin, ) @@ -378,7 +378,7 @@ class VaultSdkSourceTest { result, ) coVerify { - clientCrypto.derivePinUserKey(encryptedPin = encryptedPin) + clientCrypto.enrollPinWithEncryptedPin(encryptedPin = encryptedPin) } coVerify { sdkClientManager.getOrCreateClient(userId = userId) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt index 80ce44e5c5..a657cb9c73 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import app.cash.turbine.test +import com.bitwarden.core.EnrollPinResponse import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.InitUserCryptoRequest @@ -1635,6 +1636,134 @@ class VaultLockManagerTest { } } + @Suppress("MaxLineLength") + @Test + fun `unlockVault with initializeCrypto success should migrate pinProtectedUserKey`() = + runTest { + val kdf = MOCK_PROFILE.toSdkParams() + val email = MOCK_PROFILE.email + val masterPassword = "drowssap" + val userKey = "12345" + val privateKey = "54321" + val organizationKeys = mapOf("orgId1" to "orgKey1") + val userKeyEncryptedPin = "encryptedPin" + val pinProtectedUserKeyEnvelope = "pinProtectedUserKeyEnvelope" + val enrollResponse = EnrollPinResponse( + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, + userKeyEncryptedPin = userKeyEncryptedPin, + ) + coEvery { + vaultSdkSource.initializeCrypto( + userId = USER_ID, + request = InitUserCryptoRequest( + userId = USER_ID, + kdfParams = kdf, + email = email, + privateKey = privateKey, + method = InitUserCryptoMethod.Password( + password = masterPassword, + userKey = userKey, + ), + signingKey = null, + securityState = null, + ), + ) + } returns InitializeCryptoResult.Success.asSuccess() + coEvery { + vaultSdkSource.initializeOrganizationCrypto( + userId = USER_ID, + request = InitOrgCryptoRequest(organizationKeys = organizationKeys), + ) + } returns InitializeCryptoResult.Success.asSuccess() + coEvery { + vaultSdkSource.enrollPinWithEncryptedPin( + userId = USER_ID, + encryptedPin = userKeyEncryptedPin, + ) + } returns enrollResponse.asSuccess() + coEvery { + trustedDeviceManager.trustThisDeviceIfNecessary(userId = USER_ID) + } returns false.asSuccess() + assertEquals( + emptyList(), + vaultLockManager.vaultUnlockDataStateFlow.value, + ) + mutableVaultTimeoutStateFlow.value = VaultTimeout.ThirtyMinutes + fakeAuthDiskSource.storeUserAutoUnlockKey( + userId = USER_ID, + userAutoUnlockKey = null, + ) + fakeAuthDiskSource.storeEncryptedPin( + userId = USER_ID, + encryptedPin = userKeyEncryptedPin, + ) + fakeAuthDiskSource.storePinProtectedUserKey( + userId = USER_ID, + pinProtectedUserKey = userKeyEncryptedPin, + ) + + val result = vaultLockManager.unlockVault( + userId = USER_ID, + email = email, + kdf = kdf, + privateKey = privateKey, + signingKey = null, + securityState = null, + initUserCryptoMethod = InitUserCryptoMethod.Password( + password = masterPassword, + userKey = userKey, + ), + organizationKeys = organizationKeys, + ) + + assertEquals(VaultUnlockResult.Success, result) + assertEquals( + listOf( + VaultUnlockData( + userId = USER_ID, + status = VaultUnlockData.Status.UNLOCKED, + ), + ), + vaultLockManager.vaultUnlockDataStateFlow.value, + ) + + fakeAuthDiskSource.assertUserAutoUnlockKey( + userId = USER_ID, + userAutoUnlockKey = null, + ) + fakeAuthDiskSource.assertMasterPasswordHash( + userId = USER_ID, + passwordHash = "hashedPassword", + ) + fakeAuthDiskSource.assertPinProtectedUserKeyEnvelope( + userId = USER_ID, + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, + inMemoryOnly = false, + ) + coVerify(exactly = 1) { + vaultSdkSource.initializeCrypto( + userId = USER_ID, + request = InitUserCryptoRequest( + userId = USER_ID, + kdfParams = kdf, + email = email, + privateKey = privateKey, + method = InitUserCryptoMethod.Password( + password = masterPassword, + userKey = userKey, + ), + signingKey = null, + securityState = null, + ), + ) + vaultSdkSource.initializeOrganizationCrypto( + userId = USER_ID, + request = InitOrgCryptoRequest(organizationKeys = organizationKeys), + ) + trustedDeviceManager.trustThisDeviceIfNecessary(userId = USER_ID) + } + } + /** * Resets the verification call count for the given [mock] while leaving all other mocked * behavior in place. diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 251aeecf2d..4e5b61c21a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.repository import app.cash.turbine.test import com.bitwarden.collections.CollectionView import com.bitwarden.core.DateTime +import com.bitwarden.core.EnrollPinResponse import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.util.asFailure @@ -233,7 +234,7 @@ class VaultRepositoryTest { ) } coVerify(exactly = 0) { - vaultSdkSource.derivePinProtectedUserKey(any(), any()) + vaultSdkSource.enrollPinWithEncryptedPin(any(), any()) } fakeAuthDiskSource.apply { assertBiometricsKey( @@ -271,7 +272,7 @@ class VaultRepositoryTest { result, ) coVerify(exactly = 0) { - vaultSdkSource.derivePinProtectedUserKey(any(), any()) + vaultSdkSource.enrollPinWithEncryptedPin(any(), any()) } } @@ -298,7 +299,7 @@ class VaultRepositoryTest { result, ) coVerify(exactly = 0) { - vaultSdkSource.derivePinProtectedUserKey(any(), any()) + vaultSdkSource.enrollPinWithEncryptedPin(any(), any()) } } @@ -353,7 +354,7 @@ class VaultRepositoryTest { ) } coVerify(exactly = 0) { - vaultSdkSource.derivePinProtectedUserKey(any(), any()) + vaultSdkSource.enrollPinWithEncryptedPin(any(), any()) } } @@ -364,7 +365,11 @@ class VaultRepositoryTest { val userId = MOCK_USER_STATE.activeUserId val encryptedPin = "encryptedPin" val privateKey = "mockPrivateKey-1" - val pinProtectedUserKey = "pinProtectedUserkey" + val pinProtectedUserKeyEnvelope = "pinProtectedUserKeyEnvelope" + val enrollResponse = EnrollPinResponse( + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, + userKeyEncryptedPin = encryptedPin, + ) val biometricsKey = "asdf1234" fakeAuthDiskSource.userState = MOCK_USER_STATE val encryptedBytes = byteArrayOf(1, 1) @@ -374,11 +379,11 @@ class VaultRepositoryTest { every { iv } returns initVector } coEvery { - vaultSdkSource.derivePinProtectedUserKey( + vaultSdkSource.enrollPinWithEncryptedPin( userId = userId, encryptedPin = encryptedPin, ) - } returns pinProtectedUserKey.asSuccess() + } returns enrollResponse.asSuccess() coEvery { vaultLockManager.unlockVault( userId = userId, @@ -403,6 +408,11 @@ class VaultRepositoryTest { pinProtectedUserKey = null, inMemoryOnly = true, ) + storePinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = null, + inMemoryOnly = true, + ) } val result = vaultRepository.unlockVaultWithBiometrics(cipher = cipher) @@ -410,7 +420,12 @@ class VaultRepositoryTest { assertEquals(VaultUnlockResult.Success, result) fakeAuthDiskSource.assertPinProtectedUserKey( userId = userId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKey = null, + inMemoryOnly = true, + ) + fakeAuthDiskSource.assertPinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, inMemoryOnly = true, ) coVerify { @@ -428,7 +443,7 @@ class VaultRepositoryTest { ) } coEvery { - vaultSdkSource.derivePinProtectedUserKey( + vaultSdkSource.enrollPinWithEncryptedPin( userId = userId, encryptedPin = encryptedPin, ) @@ -603,9 +618,15 @@ class VaultRepositoryTest { runTest { val userId = "mockId-1" val mockVaultUnlockResult = VaultUnlockResult.Success + val userKeyEncryptedPin = "encryptedPin" + val pinProtectedUserKeyEnvelope = "pinProtectedUserKeyEnvelope" + val enrollResponse = EnrollPinResponse( + pinProtectedUserKeyEnvelope = pinProtectedUserKeyEnvelope, + userKeyEncryptedPin = userKeyEncryptedPin, + ) coEvery { - vaultSdkSource.derivePinProtectedUserKey(any(), any()) - } returns "pinProtectedUserKey".asSuccess() + vaultSdkSource.enrollPinWithEncryptedPin(any(), any()) + } returns enrollResponse.asSuccess() prepareStateForUnlocking(unlockResult = mockVaultUnlockResult) fakeAuthDiskSource.apply { storeEncryptedPin( @@ -617,6 +638,11 @@ class VaultRepositoryTest { pinProtectedUserKey = null, inMemoryOnly = true, ) + storePinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = null, + inMemoryOnly = true, + ) } val result = vaultRepository.unlockVaultWithMasterPassword( @@ -642,7 +668,7 @@ class VaultRepositoryTest { organizationKeys = createMockOrganizationKeys(number = 1), ) } - coVerify(exactly = 0) { vaultSdkSource.derivePinProtectedUserKey(any(), any()) } + coVerify(exactly = 0) { vaultSdkSource.enrollPinWithEncryptedPin(any(), any()) } } @Suppress("MaxLineLength") @@ -650,15 +676,19 @@ class VaultRepositoryTest { fun `unlockVaultWithMasterPassword 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 = "mockId-1" - val encryptedPin = "encryptedPin" - val pinProtectedUserKey = "pinProtectedUserkey" + val pinProtectedUserKey = "pinProtectedUserkeyEnvelope" + val encryptedPin = "userKeyEncryptedPin" + val enrollResponse = EnrollPinResponse( + pinProtectedUserKeyEnvelope = pinProtectedUserKey, + userKeyEncryptedPin = encryptedPin, + ) val mockVaultUnlockResult = VaultUnlockResult.Success coEvery { - vaultSdkSource.derivePinProtectedUserKey( + vaultSdkSource.enrollPinWithEncryptedPin( userId = userId, encryptedPin = encryptedPin, ) - } returns pinProtectedUserKey.asSuccess() + } returns enrollResponse.asSuccess() prepareStateForUnlocking(unlockResult = mockVaultUnlockResult) fakeAuthDiskSource.apply { storeEncryptedPin( @@ -670,6 +700,11 @@ class VaultRepositoryTest { pinProtectedUserKey = null, inMemoryOnly = true, ) + storePinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = null, + inMemoryOnly = true, + ) } val result = vaultRepository.unlockVaultWithMasterPassword( @@ -682,7 +717,12 @@ class VaultRepositoryTest { ) fakeAuthDiskSource.assertPinProtectedUserKey( userId = userId, - pinProtectedUserKey = pinProtectedUserKey, + pinProtectedUserKey = null, + inMemoryOnly = true, + ) + fakeAuthDiskSource.assertPinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = pinProtectedUserKey, inMemoryOnly = true, ) coVerify { @@ -701,7 +741,7 @@ class VaultRepositoryTest { ) } coEvery { - vaultSdkSource.derivePinProtectedUserKey( + vaultSdkSource.enrollPinWithEncryptedPin( userId = userId, encryptedPin = encryptedPin, ) @@ -762,6 +802,10 @@ class VaultRepositoryTest { userId = "mockId-1", pinProtectedUserKey = null, ) + fakeAuthDiskSource.storePinProtectedUserKeyEnvelope( + userId = "mockId-1", + pinProtectedUserKeyEnvelope = null, + ) fakeAuthDiskSource.storePrivateKey( userId = "mockId-1", privateKey = "mockPrivateKey-1", @@ -780,6 +824,10 @@ class VaultRepositoryTest { userId = "mockId-1", pinProtectedUserKey = "mockKey-1", ) + fakeAuthDiskSource.storePinProtectedUserKeyEnvelope( + userId = "mockId-1", + pinProtectedUserKeyEnvelope = null, + ) fakeAuthDiskSource.storePrivateKey( userId = "mockId-1", privateKey = null, @@ -801,6 +849,41 @@ class VaultRepositoryTest { val result = vaultRepository.unlockVaultWithPin(pin = "1234") + assertEquals( + mockVaultUnlockResult, + result, + ) + coVerify { + vaultLockManager.unlockVault( + userId = userId, + email = "email", + kdf = MOCK_PROFILE.toSdkParams(), + privateKey = "mockPrivateKey-1", + signingKey = null, + securityState = null, + initUserCryptoMethod = InitUserCryptoMethod.PinEnvelope( + pin = "1234", + pinProtectedUserKeyEnvelope = "mockKey-1", + ), + organizationKeys = createMockOrganizationKeys(number = 1), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `unlockVaultWithPin with PinProtectedUserKeyEnvelope null and VaultLockManager Success should unlock with pin for the current user and return Success`() = + runTest { + val userId = "mockId-1" + val mockVaultUnlockResult = VaultUnlockResult.Success + prepareStateForUnlocking(unlockResult = mockVaultUnlockResult) + + fakeAuthDiskSource.storePinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = null, + ) + + val result = vaultRepository.unlockVaultWithPin(pin = "1234") assertEquals( mockVaultUnlockResult, result, @@ -844,9 +927,9 @@ class VaultRepositoryTest { privateKey = "mockPrivateKey-1", signingKey = null, securityState = null, - initUserCryptoMethod = InitUserCryptoMethod.Pin( + initUserCryptoMethod = InitUserCryptoMethod.PinEnvelope( pin = "1234", - pinProtectedUserKey = "mockKey-1", + pinProtectedUserKeyEnvelope = "mockKey-1", ), organizationKeys = createMockOrganizationKeys(number = 1), ) @@ -1432,6 +1515,10 @@ class VaultRepositoryTest { userId = userId, pinProtectedUserKey = "mockKey-1", ) + fakeAuthDiskSource.storePinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = "mockKey-1", + ) fakeAuthDiskSource.storeOrganizationKeys( userId = userId, organizationKeys = createMockOrganizationKeys(number = 1), @@ -1471,6 +1558,23 @@ class VaultRepositoryTest { organizationKeys = createMockOrganizationKeys(number = 1), ) } returns unlockResult + + // PIN ENVELOPE unlock + coEvery { + vaultLockManager.unlockVault( + userId = userId, + email = "email", + kdf = MOCK_PROFILE.toSdkParams(), + privateKey = "mockPrivateKey-1", + signingKey = null, + securityState = null, + initUserCryptoMethod = InitUserCryptoMethod.PinEnvelope( + pin = mockPin, + pinProtectedUserKeyEnvelope = "mockKey-1", + ), + organizationKeys = createMockOrganizationKeys(number = 1), + ) + } returns unlockResult } //endregion Helper functions }