From d287e0c79a155033f398752bda332ea1db205d5b Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 22 Jul 2026 15:16:37 -0500 Subject: [PATCH 1/3] PM-37879: feat: Add support for v2UpgradeToken in sync response --- .../auth/datasource/disk/AuthDiskSource.kt | 11 ++++ .../datasource/disk/AuthDiskSourceImpl.kt | 17 +++++++ .../vault/manager/VaultSyncManagerImpl.kt | 4 ++ .../util/V2UpgradeTokenExtensions.kt | 22 ++++++++ .../datasource/disk/AuthDiskSourceTest.kt | 50 ++++++++++++++++++- .../disk/util/FakeAuthDiskSource.kt | 17 +++++++ .../util/UserStateJsonExtensionsTest.kt | 9 +++- .../vault/manager/VaultSyncManagerTest.kt | 7 ++- .../util/V2UpgradeTokenExtensionsTest.kt | 36 +++++++++++++ .../network/model/UserDecryptionJson.kt | 8 +++ .../network/model/V2UpgradeTokenJson.kt | 16 ++++++ .../network/service/SyncServiceTest.kt | 4 ++ .../network/model/SyncResponseUtil.kt | 15 ++++++ 13 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/V2UpgradeTokenExtensions.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/V2UpgradeTokenExtensionsTest.kt create mode 100644 network/src/main/kotlin/com/bitwarden/network/model/V2UpgradeTokenJson.kt 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 5c1df0475d7..dfee0830d23 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 @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk import com.bitwarden.core.WrappedAccountCryptographicState import com.bitwarden.network.model.SyncResponseJson +import com.bitwarden.network.model.V2UpgradeTokenJson import com.bitwarden.network.provider.AppIdProvider import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus @@ -410,4 +411,14 @@ interface AuthDiskSource : AppIdProvider { * Stores the last lock timestamp for the given [userId]. */ fun storeLastLockTimestamp(userId: String, lastLockTimestamp: Instant?) + + /** + * Gets the v2 upgrade token for the given [userId]. + */ + fun getV2UpgradeToken(userId: String): V2UpgradeTokenJson? + + /** + * Stores the v2 upgrade token for the given [userId]. + */ + fun storeV2UpgradeToken(userId: String, v2UpgradeToken: V2UpgradeTokenJson?) } 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 07033fd4fd6..223a9c8ae01 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 @@ -8,6 +8,7 @@ import com.bitwarden.core.data.util.decodeFromStringOrNull import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource import com.bitwarden.network.model.AccountKeysJson import com.bitwarden.network.model.SyncResponseJson +import com.bitwarden.network.model.V2UpgradeTokenJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson @@ -58,6 +59,7 @@ private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins" private const val LAST_LOCK_TIMESTAMP = "lastLockTimestamp" private const val PROFILE_ACCOUNT_KEYS_KEY = "profileAccountKeys" private const val ACCOUNT_CRYPTOGRAPHIC_STATE_KEY = "accountCryptographicState" +private const val V2_UPGRADE_TOKEN = "v2UpgradeToken" /** * Primary implementation of [AuthDiskSource]. @@ -192,6 +194,7 @@ class AuthDiskSourceImpl( userId = userId, pinProtectedUserKeyEnvelope = null, ) + storeV2UpgradeToken(userId = userId, v2UpgradeToken = null) // Certain values are never removed as required by the feature requirements: // * DeviceKey @@ -603,6 +606,20 @@ class AuthDiskSourceImpl( ) } + override fun getV2UpgradeToken( + userId: String, + ): V2UpgradeTokenJson? = + getString(key = V2_UPGRADE_TOKEN.appendIdentifier(identifier = userId))?.let { + json.decodeFromStringOrNull(string = it) + } + + override fun storeV2UpgradeToken(userId: String, v2UpgradeToken: V2UpgradeTokenJson?) { + putString( + key = V2_UPGRADE_TOKEN.appendIdentifier(identifier = userId), + value = v2UpgradeToken?.let { json.encodeToString(value = it) }, + ) + } + private fun generateAndStoreUniqueAppId(): String = UUID .randomUUID() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt index bdf1791befa..b924b3dfdc7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt @@ -328,6 +328,10 @@ class VaultSyncManagerImpl( authDiskSource.userState = authDiskSource.userState?.toUpdatedUserStateJson( syncResponse = syncResponse, ) + authDiskSource.storeV2UpgradeToken( + userId = userId, + v2UpgradeToken = syncResponse.userDecryption?.v2UpgradeToken, + ) unlockVaultForOrganizationsIfNecessary(syncResponse = syncResponse) storeProfileData(syncResponse = syncResponse) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/V2UpgradeTokenExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/V2UpgradeTokenExtensions.kt new file mode 100644 index 00000000000..5fc70aa1203 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/V2UpgradeTokenExtensions.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.vault.repository.util + +import com.bitwarden.core.V2UpgradeToken +import com.bitwarden.network.model.V2UpgradeTokenJson + +/** + * Converts the [V2UpgradeToken] into a [V2UpgradeTokenJson]. + */ +fun V2UpgradeToken.toV2UpgradeTokenJson(): V2UpgradeTokenJson = + V2UpgradeTokenJson( + wrappedUserKey1 = this.wrappedUserKey1, + wrappedUserKey2 = this.wrappedUserKey2, + ) + +/** + * Converts the [V2UpgradeTokenJson] into a [V2UpgradeToken]. + */ +fun V2UpgradeTokenJson.toV2UpgradeToken(): V2UpgradeToken = + V2UpgradeToken( + wrappedUserKey1 = this.wrappedUserKey1, + wrappedUserKey2 = this.wrappedUserKey2, + ) 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 912515cf845..2173458f5b7 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 @@ -11,6 +11,7 @@ import com.bitwarden.network.model.KdfTypeJson import com.bitwarden.network.model.KeyConnectorUserDecryptionOptionsJson import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson import com.bitwarden.network.model.UserDecryptionOptionsJson +import com.bitwarden.network.model.V2UpgradeTokenJson import com.bitwarden.network.model.createMockOrganizationNetwork import com.bitwarden.network.model.createMockPolicy import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson @@ -327,11 +328,17 @@ class AuthDiskSourceTest { userId = userId, authenticatorSyncUnlockKey = "authenticatorSyncUnlockKey", ) - authDiskSource.storeOnboardingStatus( userId = userId, onboardingStatus = OnboardingStatus.AUTOFILL_SETUP, ) + authDiskSource.storeV2UpgradeToken( + userId = userId, + v2UpgradeToken = V2UpgradeTokenJson( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", + ), + ) authDiskSource.clearData(userId = userId) @@ -364,6 +371,7 @@ class AuthDiskSourceTest { assertNull(authDiskSource.getPinProtectedUserKeyEnvelope(userId = userId)) assertNull(authDiskSource.getEphemeralPinProtectedUserKeyEnvelope(userId = userId)) assertNull(authDiskSource.getPersistentPinProtectedUserKeyEnvelope(userId = userId)) + assertNull(authDiskSource.getV2UpgradeToken(userId = userId)) } @Test @@ -492,6 +500,46 @@ class AuthDiskSourceTest { ) } + @Test + fun `getV2UpgradeToken should pull from SharedPreferences`() { + val v2UpgradeTokenBaseKey = "bwPreferencesStorage:v2UpgradeToken" + val mockUserId = "mockUserId" + val mockV2UpgradeToken = V2UpgradeTokenJson( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", + ) + fakeSharedPreferences.edit { + putString( + "${v2UpgradeTokenBaseKey}_$mockUserId", + json.encodeToString(mockV2UpgradeToken), + ) + } + val actual = authDiskSource.getV2UpgradeToken(userId = mockUserId) + assertEquals(mockV2UpgradeToken, actual) + } + + @Test + fun `storeV2UpgradeToken should update SharedPreferences`() { + val v2UpgradeTokenBaseKey = "bwPreferencesStorage:v2UpgradeToken" + val mockUserId = "mockUserId" + val mockV2UpgradeToken = V2UpgradeTokenJson( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", + ) + authDiskSource.storeV2UpgradeToken( + userId = mockUserId, + v2UpgradeToken = mockV2UpgradeToken, + ) + val actual = fakeSharedPreferences.getString( + "${v2UpgradeTokenBaseKey}_$mockUserId", + null, + ) + assertEquals( + json.encodeToJsonElement(mockV2UpgradeToken), + json.parseToJsonElement(requireNotNull(actual)), + ) + } + @Test fun `getTwoFactorToken should pull from SharedPreferences`() { val twoFactorTokenBaseKey = "bwPreferencesStorage:twoFactorToken" 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 8b8565945cf..594d23004fb 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 @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.util import com.bitwarden.core.WrappedAccountCryptographicState import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.network.model.SyncResponseJson +import com.bitwarden.network.model.V2UpgradeTokenJson import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus @@ -71,6 +72,7 @@ class FakeAuthDiskSource : AuthDiskSource { mutableMapOf>() private val mutablePersistentPinProtectedUserKeyEnvelopesFlowMap = mutableMapOf>() + private val storedV2UpgradeTokens = mutableMapOf() override var userState: UserStateJson? = null set(value) { @@ -97,6 +99,7 @@ class FakeAuthDiskSource : AuthDiskSource { storedPersistentPinProtectedUserKeyEnvelopes.remove(userId) storedEncryptedPins.remove(userId) storedPinProtectedUserKeys.remove(userId) + storedV2UpgradeTokens.remove(userId) mutableShouldUseKeyConnectorFlowMap.remove(userId) mutableOrganizationsFlowMap.remove(userId) @@ -160,6 +163,13 @@ class FakeAuthDiskSource : AuthDiskSource { storedAccountCryptographicState[userId] = accountCryptographicState } + override fun getV2UpgradeToken(userId: String): V2UpgradeTokenJson? = + storedV2UpgradeTokens[userId] + + override fun storeV2UpgradeToken(userId: String, v2UpgradeToken: V2UpgradeTokenJson?) { + storedV2UpgradeTokens[userId] = v2UpgradeToken + } + override fun getTwoFactorToken(email: String): String? = storedTwoFactorTokens[email] override fun storeTwoFactorToken(email: String, twoFactorToken: String?) { @@ -439,6 +449,13 @@ class FakeAuthDiskSource : AuthDiskSource { assertEquals(accountCryptographicState, storedAccountCryptographicState[userId]) } + /** + * Assert that the [v2UpgradeToken] was stored successfully using the [userId]. + */ + fun assertV2UpgradeToken(userId: String, v2UpgradeToken: V2UpgradeTokenJson?) { + assertEquals(v2UpgradeToken, storedV2UpgradeTokens[userId]) + } + /** * Assert that the [twoFactorToken] was stored successfully using the [email]. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index 97da2c3c2dd..e193dfe08e7 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -1903,6 +1903,7 @@ class UserStateJsonExtensionsTest { ), userDecryption = UserDecryptionJson( masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA, + v2UpgradeToken = null, ), ) @@ -1994,6 +1995,7 @@ class UserStateJsonExtensionsTest { ), userDecryption = UserDecryptionJson( masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA, + v2UpgradeToken = null, ), ) @@ -2158,6 +2160,7 @@ class UserStateJsonExtensionsTest { ), masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey", ), + v2UpgradeToken = null, ), ) @@ -2245,7 +2248,10 @@ class UserStateJsonExtensionsTest { ), ), ), - userDecryption = UserDecryptionJson(masterPasswordUnlock = null), + userDecryption = UserDecryptionJson( + masterPasswordUnlock = null, + v2UpgradeToken = null, + ), ), ) @@ -2313,6 +2319,7 @@ class UserStateJsonExtensionsTest { ), userDecryption = UserDecryptionJson( masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA, + v2UpgradeToken = null, ), ), ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt index 0321c4f65ae..4cb4eb135ec 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt @@ -24,6 +24,7 @@ import com.bitwarden.network.model.createMockPolicy import com.bitwarden.network.model.createMockProfile import com.bitwarden.network.model.createMockSend import com.bitwarden.network.model.createMockSyncResponse +import com.bitwarden.network.model.createMockV2UpgradeToken import com.bitwarden.network.service.SyncService import com.bitwarden.send.SendView import com.bitwarden.vault.DecryptCipherListResult @@ -34,9 +35,9 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.manager.UserStateManager -import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.createMockWrappedAccountCryptographicState +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.error.NoActiveUserException import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager @@ -758,6 +759,10 @@ class VaultSyncManagerTest { userId = userId, accountCryptographicState = createMockWrappedAccountCryptographicState(number = 1), ) + fakeAuthDiskSource.assertV2UpgradeToken( + userId = userId, + v2UpgradeToken = createMockV2UpgradeToken(number = 1), + ) fakeAuthDiskSource.assertOrganizationKeys( userId = userId, organizationKeys = mapOf(userId to "mockKey-1"), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/V2UpgradeTokenExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/V2UpgradeTokenExtensionsTest.kt new file mode 100644 index 00000000000..f9c2cb45f90 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/util/V2UpgradeTokenExtensionsTest.kt @@ -0,0 +1,36 @@ +package com.x8bit.bitwarden.data.vault.repository.util + +import com.bitwarden.core.V2UpgradeToken +import com.bitwarden.network.model.V2UpgradeTokenJson +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class V2UpgradeTokenExtensionsTest { + @Test + fun `toV2UpgradeTokenJson maps all fields to a V2UpgradeTokenJson`() { + val token = V2UpgradeToken( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", + ) + val expected = V2UpgradeTokenJson( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", + ) + + assertEquals(expected, token.toV2UpgradeTokenJson()) + } + + @Test + fun `toV2UpgradeToken maps all fields to a V2UpgradeToken`() { + val json = V2UpgradeTokenJson( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", + ) + val expected = V2UpgradeToken( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", + ) + + assertEquals(expected, json.toV2UpgradeToken()) + } +} diff --git a/network/src/main/kotlin/com/bitwarden/network/model/UserDecryptionJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/UserDecryptionJson.kt index 94f5b1d509a..37cc71bceeb 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/UserDecryptionJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/UserDecryptionJson.kt @@ -5,9 +5,17 @@ import kotlinx.serialization.Serializable /** * Represents the user decryption options received on sync. + * + * @property masterPasswordUnlock The unlock data when the user has a master password that can be + * used to decrypt their vault. + * @property v2UpgradeToken The V2 upgrade token returned when available, allowing vault unlock + * after V1 → V2 upgrade. */ @Serializable data class UserDecryptionJson( @SerialName("masterPasswordUnlock") val masterPasswordUnlock: MasterPasswordUnlockDataJson?, + + @SerialName("v2UpgradeToken") + val v2UpgradeToken: V2UpgradeTokenJson?, ) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/V2UpgradeTokenJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/V2UpgradeTokenJson.kt new file mode 100644 index 00000000000..240b35feb30 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/V2UpgradeTokenJson.kt @@ -0,0 +1,16 @@ +package com.bitwarden.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents the V2 upgrade token, allowing vault unlock after V1 → V2 upgrade. + */ +@Serializable +data class V2UpgradeTokenJson( + @SerialName("wrappedUserKey1") + val wrappedUserKey1: String, + + @SerialName("wrappedUserKey2") + val wrappedUserKey2: String, +) diff --git a/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt index ec9e2d22f74..ce77104580e 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt @@ -597,6 +597,10 @@ private const val SYNC_SUCCESS_JSON = """ }, "masterKeyWrappedUserKey": "mockMasterKeyWrappedUserKey-1", "salt": "mockSalt-1" + }, + "v2UpgradeToken": { + "wrappedUserKey1": "mockWrappedUserKey1-1", + "wrappedUserKey2": "mockWrappedUserKey2-1" } } } diff --git a/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseUtil.kt b/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseUtil.kt index c76fa050806..db84e8c1309 100644 --- a/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseUtil.kt +++ b/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseUtil.kt @@ -35,9 +35,24 @@ fun createMockUserDecryption( masterPasswordUnlock: MasterPasswordUnlockDataJson? = createMockMasterPasswordUnlock( number = number, ), + v2UpgradeToken: V2UpgradeTokenJson? = createMockV2UpgradeToken(number = number), ): UserDecryptionJson = UserDecryptionJson( masterPasswordUnlock = masterPasswordUnlock, + v2UpgradeToken = v2UpgradeToken, + ) + +/** + * Create a mock [V2UpgradeTokenJson] with a given [number]. + */ +fun createMockV2UpgradeToken( + number: Int, + wrappedUserKey1: String = "mockWrappedUserKey1-$number", + wrappedUserKey2: String = "mockWrappedUserKey2-$number", +): V2UpgradeTokenJson = + V2UpgradeTokenJson( + wrappedUserKey1 = wrappedUserKey1, + wrappedUserKey2 = wrappedUserKey2, ) /** From 37fac24cb284ddad72938080ed502760d404c21e Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 22 Jul 2026 15:30:23 -0500 Subject: [PATCH 2/3] PM-37879: feat: Add the SdkStateBridge --- .../platform/manager/SdkClientManagerImpl.kt | 5 +- .../manager/sdk/SdkRepositoryFactory.kt | 6 + .../manager/sdk/SdkRepositoryFactoryImpl.kt | 10 + .../manager/sdk/statebridge/SdkStateBridge.kt | 137 +++++++ .../manager/sdk/SdkRepositoryFactoryTests.kt | 15 + .../sdk/statebridge/SdkStateBridgeTest.kt | 368 ++++++++++++++++++ 6 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridge.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridgeTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/SdkClientManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/SdkClientManagerImpl.kt index 5f176f40231..ed9d3e3adad 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/SdkClientManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/SdkClientManagerImpl.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.runBlocking /** * Primary implementation of [SdkClientManager]. */ -class SdkClientManagerImpl( +internal class SdkClientManagerImpl( nativeLibraryManager: NativeLibraryManager, dispatcherManager: DispatcherManager, sdkRepoFactory: SdkRepositoryFactory, @@ -42,6 +42,9 @@ class SdkClientManagerImpl( platform().state().registerClientManagedRepositories( repositories = sdkRepoFactory.getRepositories(userId = userId), ) + userId?.let { + kmStateBridge().registerBridgeImpl(sdkRepoFactory.getStateBridge(userId = it)) + } } }, ) : SdkClientManager { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactory.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactory.kt index e1efaeb3094..2d7fd0bdb7b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactory.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactory.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.manager.sdk import com.bitwarden.core.ClientManagedTokens import com.bitwarden.core.ClientSettings +import com.bitwarden.core.StateBridgeForeignImpl import com.bitwarden.sdk.Repositories import com.bitwarden.sdk.ServerCommunicationConfigRepository @@ -9,6 +10,11 @@ import com.bitwarden.sdk.ServerCommunicationConfigRepository * Creates and manages sdk repositories. */ interface SdkRepositoryFactory { + /** + * Creates a [StateBridgeForeignImpl] for use with the Bitwarden SDK. + */ + fun getStateBridge(userId: String): StateBridgeForeignImpl + /** * Retrieves or creates a [Repositories] for use with the Bitwarden SDK. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryImpl.kt index cedcd5468f3..61a30abb2c6 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryImpl.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.manager.sdk import com.bitwarden.core.ClientManagedTokens import com.bitwarden.core.ClientSettings import com.bitwarden.core.DeviceType +import com.bitwarden.core.StateBridgeForeignImpl import com.bitwarden.data.datasource.disk.ConfigDiskSource import com.bitwarden.network.model.BitwardenServiceClientConfig import com.bitwarden.sdk.Repositories @@ -13,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkCipherReposit import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkLocalUserDataKeyStateRepository import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkTokenRepository import com.x8bit.bitwarden.data.platform.manager.sdk.repository.ServerCommunicationConfigRepositoryImpl +import com.x8bit.bitwarden.data.platform.manager.sdk.statebridge.SdkStateBridge import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource /** @@ -25,6 +27,14 @@ class SdkRepositoryFactoryImpl( private val authDiskSource: AuthDiskSource, private val serviceClientConfig: BitwardenServiceClientConfig, ) : SdkRepositoryFactory { + override fun getStateBridge( + userId: String, + ): StateBridgeForeignImpl = + SdkStateBridge( + userId = userId, + authDiskSource = authDiskSource, + ) + override fun getRepositories(userId: String?): Repositories = Repositories( cipher = getSdkCipherRepository(userId = userId), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridge.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridge.kt new file mode 100644 index 00000000000..024fe0552c5 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridge.kt @@ -0,0 +1,137 @@ +package com.x8bit.bitwarden.data.platform.manager.sdk.statebridge + +import com.bitwarden.core.MasterPasswordUnlockData +import com.bitwarden.core.StateBridgeForeignImpl +import com.bitwarden.core.V2UpgradeToken +import com.bitwarden.core.WrappedAccountCryptographicState +import com.bitwarden.crypto.EncString +import com.bitwarden.crypto.PasswordProtectedKeyEnvelope +import com.bitwarden.crypto.SymmetricCryptoKey +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.repository.util.updateMasterPasswordUnlock +import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock +import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeToken +import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeTokenJson + +/** + * A user-scoped implementation of a Bitwarden SDK [StateBridgeForeignImpl]. + */ +@Suppress("TooManyFunctions") +internal class SdkStateBridge( + private val userId: String, + private val authDiskSource: AuthDiskSource, +) : StateBridgeForeignImpl { + @Volatile + private var inMemoryUserKey: SymmetricCryptoKey? = null + + override suspend fun setUserKey(value: SymmetricCryptoKey) { + inMemoryUserKey = value + } + + override suspend fun getUserKey(): SymmetricCryptoKey? = inMemoryUserKey + + override suspend fun clearUserKey() { + inMemoryUserKey = null + } + + override suspend fun setPersistentPinEnvelope(value: PasswordProtectedKeyEnvelope) { + authDiskSource.storePersistentPinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = value, + ) + } + + override suspend fun getPersistentPinEnvelope(): PasswordProtectedKeyEnvelope? = + authDiskSource.getPersistentPinProtectedUserKeyEnvelope(userId = userId) + + override suspend fun clearPersistentPinEnvelope() { + authDiskSource.storePersistentPinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = null, + ) + } + + override suspend fun setEphemeralPinEnvelope(value: PasswordProtectedKeyEnvelope) { + authDiskSource.storeEphemeralPinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = value, + ) + } + + override suspend fun getEphemeralPinEnvelope(): PasswordProtectedKeyEnvelope? = + authDiskSource.getEphemeralPinProtectedUserKeyEnvelope(userId = userId) + + override suspend fun clearEphemeralPinEnvelope() { + authDiskSource.storeEphemeralPinProtectedUserKeyEnvelope( + userId = userId, + pinProtectedUserKeyEnvelope = null, + ) + } + + override suspend fun setEncryptedPin(value: EncString) { + authDiskSource.storeEncryptedPin(userId = userId, encryptedPin = value) + } + + override suspend fun getEncryptedPin(): EncString? = + authDiskSource.getEncryptedPin(userId = userId) + + override suspend fun clearEncryptedPin() { + authDiskSource.storeEncryptedPin(userId = userId, encryptedPin = null) + } + + override suspend fun setV2UpgradeToken(value: V2UpgradeToken) { + authDiskSource.storeV2UpgradeToken( + userId = userId, + v2UpgradeToken = value.toV2UpgradeTokenJson(), + ) + } + + override suspend fun getV2UpgradeToken(): V2UpgradeToken? = + authDiskSource + .getV2UpgradeToken(userId = userId) + ?.toV2UpgradeToken() + + override suspend fun clearV2UpgradeToken() { + authDiskSource.storeV2UpgradeToken(userId = userId, v2UpgradeToken = null) + } + + override suspend fun setAccountCryptographicState(value: WrappedAccountCryptographicState) { + authDiskSource.storeAccountCryptographicState( + userId = userId, + accountCryptographicState = value, + ) + } + + override suspend fun getAccountCryptographicState(): WrappedAccountCryptographicState? = + authDiskSource.getAccountCryptographicState(userId = userId) + + override suspend fun clearAccountCryptographicState() { + authDiskSource.storeAccountCryptographicState( + userId = userId, + accountCryptographicState = null, + ) + } + + override suspend fun setMasterpasswordUnlockData(value: MasterPasswordUnlockData) { + authDiskSource.userState = authDiskSource.userState?.updateMasterPasswordUnlock( + userId = userId, + masterPasswordUnlock = value, + ) + } + + override suspend fun getMasterpasswordUnlockData(): MasterPasswordUnlockData? = + authDiskSource + .userState + ?.accounts[userId] + ?.profile + ?.userDecryptionOptions + ?.masterPasswordUnlock + ?.toSdkMasterPasswordUnlock() + + override suspend fun clearMasterpasswordUnlockData() { + authDiskSource.userState = authDiskSource.userState?.updateMasterPasswordUnlock( + userId = userId, + masterPasswordUnlock = null, + ) + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt index 62b2cf10279..e9e8bf60e99 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt @@ -53,6 +53,21 @@ class SdkRepositoryFactoryTests { serviceClientConfig = serviceClientConfig, ) + @Test + fun `getStateBridge should create a new bridge`() { + val userId = "userId" + val firstBridge = sdkRepoFactory.getStateBridge(userId = userId) + + // Additional calls for the same userId should create a new bridge + val secondBridge = sdkRepoFactory.getStateBridge(userId = userId) + assertNotEquals(firstBridge, secondBridge) + + // Additional calls for different userIds should return a different bridge + val otherUserId = "otherUserId" + val thirdBridge = sdkRepoFactory.getStateBridge(userId = otherUserId) + assertNotEquals(firstBridge, thirdBridge) + } + @Test fun `getRepositories should create a new client`() { val userId = "userId" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridgeTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridgeTest.kt new file mode 100644 index 00000000000..efd4aa26e38 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridgeTest.kt @@ -0,0 +1,368 @@ +package com.x8bit.bitwarden.data.platform.manager.sdk.statebridge + +import com.bitwarden.core.MasterPasswordUnlockData +import com.bitwarden.core.V2UpgradeToken +import com.bitwarden.crypto.Kdf +import com.bitwarden.network.model.KdfJson +import com.bitwarden.network.model.KdfTypeJson +import com.bitwarden.network.model.MasterPasswordUnlockDataJson +import com.bitwarden.network.model.UserDecryptionOptionsJson +import com.bitwarden.network.model.V2UpgradeTokenJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.x8bit.bitwarden.data.auth.repository.model.createMockWrappedAccountCryptographicState +import com.x8bit.bitwarden.data.auth.repository.util.updateMasterPasswordUnlock +import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.Instant + +class SdkStateBridgeTest { + private val authDiskSource = FakeAuthDiskSource() + + private val stateBridge = SdkStateBridge( + userId = USER_ID, + authDiskSource = authDiskSource, + ) + + @Test + fun `setUserKey should store the user key in memory`() = runTest { + stateBridge.setUserKey(value = "userKey") + + assertEquals("userKey", stateBridge.getUserKey()) + } + + @Test + fun `getUserKey should return the in-memory user key`() = runTest { + assertNull(stateBridge.getUserKey()) + + stateBridge.setUserKey(value = "userKey") + + assertEquals("userKey", stateBridge.getUserKey()) + } + + @Test + fun `clearUserKey should clear the in-memory user key`() = runTest { + stateBridge.setUserKey(value = "userKey") + + stateBridge.clearUserKey() + + assertNull(stateBridge.getUserKey()) + } + + @Test + fun `setPersistentPinEnvelope should store the persistent pin envelope`() = runTest { + stateBridge.setPersistentPinEnvelope(value = "pinEnvelope") + + authDiskSource.assertPersistentPinProtectedUserKeyEnvelope( + userId = USER_ID, + pinProtectedUserKeyEnvelope = "pinEnvelope", + ) + } + + @Test + fun `getPersistentPinEnvelope should return the stored pin envelope`() = runTest { + assertNull(stateBridge.getPersistentPinEnvelope()) + + authDiskSource.storePersistentPinProtectedUserKeyEnvelope( + userId = USER_ID, + pinProtectedUserKeyEnvelope = "pinEnvelope", + ) + + assertEquals("pinEnvelope", stateBridge.getPersistentPinEnvelope()) + } + + @Test + fun `clearPersistentPinEnvelope should clear the persistent pin envelope`() = runTest { + authDiskSource.storePersistentPinProtectedUserKeyEnvelope( + userId = USER_ID, + pinProtectedUserKeyEnvelope = "pinEnvelope", + ) + + stateBridge.clearPersistentPinEnvelope() + + authDiskSource.assertPersistentPinProtectedUserKeyEnvelope( + userId = USER_ID, + pinProtectedUserKeyEnvelope = null, + ) + } + + @Test + fun `setEphemeralPinEnvelope should store the ephemeral pin envelope`() = runTest { + stateBridge.setEphemeralPinEnvelope(value = "pinEnvelope") + + authDiskSource.assertEphemeralPinProtectedUserKeyEnvelope( + userId = USER_ID, + pinProtectedUserKeyEnvelope = "pinEnvelope", + ) + } + + @Test + fun `getEphemeralPinEnvelope should return the stored pin envelope`() = runTest { + assertNull(stateBridge.getEphemeralPinEnvelope()) + + authDiskSource.storeEphemeralPinProtectedUserKeyEnvelope( + userId = USER_ID, + pinProtectedUserKeyEnvelope = "pinEnvelope", + ) + + assertEquals("pinEnvelope", stateBridge.getEphemeralPinEnvelope()) + } + + @Test + fun `clearEphemeralPinEnvelope should clear the ephemeral pin envelope`() = runTest { + authDiskSource.storeEphemeralPinProtectedUserKeyEnvelope( + userId = USER_ID, + pinProtectedUserKeyEnvelope = "pinEnvelope", + ) + + stateBridge.clearEphemeralPinEnvelope() + + authDiskSource.assertEphemeralPinProtectedUserKeyEnvelope( + userId = USER_ID, + pinProtectedUserKeyEnvelope = null, + ) + } + + @Test + fun `setEncryptedPin should store the encrypted pin`() = runTest { + stateBridge.setEncryptedPin(value = "encryptedPin") + + authDiskSource.assertEncryptedPin(userId = USER_ID, encryptedPin = "encryptedPin") + } + + @Test + fun `getEncryptedPin should return the stored encrypted pin`() = runTest { + assertNull(stateBridge.getEncryptedPin()) + + authDiskSource.storeEncryptedPin(userId = USER_ID, encryptedPin = "encryptedPin") + + assertEquals("encryptedPin", stateBridge.getEncryptedPin()) + } + + @Test + fun `clearEncryptedPin should clear the encrypted pin`() = runTest { + authDiskSource.storeEncryptedPin(userId = USER_ID, encryptedPin = "encryptedPin") + + stateBridge.clearEncryptedPin() + + authDiskSource.assertEncryptedPin(userId = USER_ID, encryptedPin = null) + } + + @Test + fun `setV2UpgradeToken should store the token as a V2UpgradeTokenJson`() = runTest { + stateBridge.setV2UpgradeToken(value = V2_UPGRADE_TOKEN) + + authDiskSource.assertV2UpgradeToken( + userId = USER_ID, + v2UpgradeToken = V2_UPGRADE_TOKEN_JSON, + ) + } + + @Test + fun `getV2UpgradeToken should return the stored token mapped to a V2UpgradeToken`() = runTest { + assertNull(stateBridge.getV2UpgradeToken()) + + authDiskSource.storeV2UpgradeToken( + userId = USER_ID, + v2UpgradeToken = V2_UPGRADE_TOKEN_JSON, + ) + + assertEquals(V2_UPGRADE_TOKEN, stateBridge.getV2UpgradeToken()) + } + + @Test + fun `clearV2UpgradeToken should clear the stored token`() = runTest { + authDiskSource.storeV2UpgradeToken( + userId = USER_ID, + v2UpgradeToken = V2_UPGRADE_TOKEN_JSON, + ) + + stateBridge.clearV2UpgradeToken() + + authDiskSource.assertV2UpgradeToken(userId = USER_ID, v2UpgradeToken = null) + } + + @Test + fun `setAccountCryptographicState should store the account cryptographic state`() = runTest { + val state = createMockWrappedAccountCryptographicState(number = 1) + + stateBridge.setAccountCryptographicState(value = state) + + authDiskSource.assertAccountCryptographicState( + userId = USER_ID, + accountCryptographicState = state, + ) + } + + @Test + fun `getAccountCryptographicState should return the stored account cryptographic state`() = + runTest { + assertNull(stateBridge.getAccountCryptographicState()) + + val state = createMockWrappedAccountCryptographicState(number = 1) + authDiskSource.storeAccountCryptographicState( + userId = USER_ID, + accountCryptographicState = state, + ) + + assertEquals(state, stateBridge.getAccountCryptographicState()) + } + + @Test + fun `clearAccountCryptographicState should clear the account cryptographic state`() = runTest { + authDiskSource.storeAccountCryptographicState( + userId = USER_ID, + accountCryptographicState = createMockWrappedAccountCryptographicState(number = 1), + ) + + stateBridge.clearAccountCryptographicState() + + authDiskSource.assertAccountCryptographicState( + userId = USER_ID, + accountCryptographicState = null, + ) + } + + @Test + fun `setMasterpasswordUnlockData should update the user state with the unlock data`() = + runTest { + authDiskSource.userState = USER_STATE + + stateBridge.setMasterpasswordUnlockData(value = MASTER_PASSWORD_UNLOCK_DATA) + + assertEquals( + USER_STATE.updateMasterPasswordUnlock( + userId = USER_ID, + masterPasswordUnlock = MASTER_PASSWORD_UNLOCK_DATA, + ), + authDiskSource.userState, + ) + } + + @Test + fun `setMasterpasswordUnlockData should do nothing when the user state is null`() = runTest { + authDiskSource.userState = null + + stateBridge.setMasterpasswordUnlockData(value = MASTER_PASSWORD_UNLOCK_DATA) + + assertNull(authDiskSource.userState) + } + + @Test + fun `getMasterpasswordUnlockData should return null when there is no unlock data`() = runTest { + authDiskSource.userState = null + + assertNull(stateBridge.getMasterpasswordUnlockData()) + } + + @Test + fun `getMasterpasswordUnlockData should return the stored unlock data as the sdk model`() = + runTest { + authDiskSource.userState = USER_STATE.copy( + accounts = mapOf( + USER_ID to ACCOUNT.copy( + profile = ACCOUNT.profile.copy( + userDecryptionOptions = UserDecryptionOptionsJson( + hasMasterPassword = true, + trustedDeviceUserDecryptionOptions = null, + keyConnectorUserDecryptionOptions = null, + masterPasswordUnlock = MASTER_PASSWORD_UNLOCK_DATA_JSON, + ), + ), + ), + ), + ) + + assertEquals( + MASTER_PASSWORD_UNLOCK_DATA_JSON.toSdkMasterPasswordUnlock(), + stateBridge.getMasterpasswordUnlockData(), + ) + } + + @Test + fun `clearMasterpasswordUnlockData should clear the unlock data from the user state`() = + runTest { + authDiskSource.userState = USER_STATE.copy( + accounts = mapOf( + USER_ID to ACCOUNT.copy( + profile = ACCOUNT.profile.copy( + userDecryptionOptions = UserDecryptionOptionsJson( + hasMasterPassword = true, + trustedDeviceUserDecryptionOptions = null, + keyConnectorUserDecryptionOptions = null, + masterPasswordUnlock = MASTER_PASSWORD_UNLOCK_DATA_JSON, + ), + ), + ), + ), + ) + + stateBridge.clearMasterpasswordUnlockData() + + assertNull(stateBridge.getMasterpasswordUnlockData()) + } +} + +private const val USER_ID: String = "userId" + +private val V2_UPGRADE_TOKEN: V2UpgradeToken = V2UpgradeToken( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", +) + +private val V2_UPGRADE_TOKEN_JSON: V2UpgradeTokenJson = V2UpgradeTokenJson( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", +) + +private val MASTER_PASSWORD_UNLOCK_DATA: MasterPasswordUnlockData = MasterPasswordUnlockData( + kdf = Kdf.Pbkdf2(iterations = 600_000u), + masterKeyWrappedUserKey = "masterKeyWrappedUserKey", + salt = "salt", +) + +private val MASTER_PASSWORD_UNLOCK_DATA_JSON: MasterPasswordUnlockDataJson = + MasterPasswordUnlockDataJson( + kdf = KdfJson( + kdfType = KdfTypeJson.PBKDF2_SHA256, + iterations = 600_000, + memory = null, + parallelism = null, + ), + masterKeyWrappedUserKey = "masterKeyWrappedUserKey", + salt = "salt", + ) + +private val ACCOUNT: AccountJson = AccountJson( + profile = AccountJson.Profile( + userId = USER_ID, + email = "email@bitwarden.com", + isEmailVerified = true, + name = "name", + stamp = null, + organizationId = null, + avatarColorHex = null, + hasPremiumPersonally = null, + hasPremiumFromOrganization = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = 600_000, + kdfMemory = null, + kdfParallelism = null, + userDecryptionOptions = null, + isTwoFactorEnabled = false, + creationDate = Instant.parse("2024-09-13T01:00:00.00Z"), + ), + tokens = mockk(), + settings = mockk(), +) + +private val USER_STATE: UserStateJson = UserStateJson( + activeUserId = USER_ID, + accounts = mapOf(USER_ID to ACCOUNT), +) From 4b40dc96804bb4624ba94115858008aa8a729b2d Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 28 Jul 2026 14:21:29 -0500 Subject: [PATCH 3/3] PM-37879: feat: Pass V2UpgradeToken to SDK during unlock --- .../vault/manager/VaultLockManagerImpl.kt | 5 +- .../vault/manager/VaultLockManagerTest.kt | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) 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 6d69d3fb381..0ff6dc089d8 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 @@ -42,6 +42,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.util.logTag import com.x8bit.bitwarden.data.vault.repository.util.statusFor +import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeToken import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.util.update import kotlinx.coroutines.CoroutineScope @@ -192,7 +193,9 @@ class VaultLockManagerImpl( email = email, method = initUserCryptoMethod, userId = userId, - upgradeToken = null, + upgradeToken = authDiskSource + .getV2UpgradeToken(userId = userId) + ?.toV2UpgradeToken(), ), ) .flatMap { result -> 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 d9d7121dd85..bc3ef27d7b6 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 @@ -9,6 +9,7 @@ import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.InitUserCryptoRequest import com.bitwarden.core.MasterPasswordUnlockData +import com.bitwarden.core.V2UpgradeToken import com.bitwarden.core.WrappedAccountCryptographicState import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager import com.bitwarden.core.data.manager.realtime.RealtimeManager @@ -38,6 +39,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResul import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult +import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeTokenJson import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -1001,6 +1003,73 @@ class VaultLockManagerTest { } } + @Suppress("MaxLineLength") + @Test + fun `unlockVault with a stored V2 upgrade token should pass the mapped token to initializeCrypto`() = + runTest { + val kdf = MOCK_PROFILE.toSdkParams() + val email = MOCK_PROFILE.email + val masterPassword = "mockValue" + val v2UpgradeToken = V2UpgradeToken( + wrappedUserKey1 = "wrappedUserKey1", + wrappedUserKey2 = "wrappedUserKey2", + ) + fakeAuthDiskSource.storeV2UpgradeToken( + userId = USER_ID, + v2UpgradeToken = v2UpgradeToken.toV2UpgradeTokenJson(), + ) + coEvery { + vaultSdkSource.initializeCrypto( + userId = USER_ID, + request = InitUserCryptoRequest( + accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE, + userId = USER_ID, + kdfParams = kdf, + email = email, + method = InitUserCryptoMethod.MasterPasswordUnlock( + password = masterPassword, + masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA, + ), + upgradeToken = v2UpgradeToken, + ), + ) + } returns InitializeCryptoResult.Success.asSuccess() + coEvery { + trustedDeviceManager.trustThisDeviceIfNecessary(userId = USER_ID) + } returns false.asSuccess() + mutableVaultTimeoutStateFlow.value = VaultTimeout.ThirtyMinutes + + val result = vaultLockManager.unlockVault( + accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE, + userId = USER_ID, + email = email, + kdf = kdf, + initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock( + password = masterPassword, + masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA, + ), + organizationKeys = null, + ) + + assertEquals(VaultUnlockResult.Success, result) + coVerify(exactly = 1) { + vaultSdkSource.initializeCrypto( + userId = USER_ID, + request = InitUserCryptoRequest( + accountCryptographicState = ACCOUNT_CRYPTOGRAPHIC_STATE, + userId = USER_ID, + kdfParams = kdf, + email = email, + method = InitUserCryptoMethod.MasterPasswordUnlock( + password = masterPassword, + masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA, + ), + upgradeToken = v2UpgradeToken, + ), + ) + } + } + @Suppress("MaxLineLength") @Test fun `unlockVault with initializeCrypto success for a Never VaultTimeout should return Success, save the auto-unlock key, and clear invalid unlock attempts`() =