[PM-27176] Switch to using SDK's init crypto with MasterPasswordUnlock (#6073)

This commit is contained in:
André Bispo 2025-10-24 14:56:44 +01:00 committed by GitHub
parent 78b1676745
commit 7d7951d4ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 353 additions and 123 deletions

View File

@ -51,7 +51,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdf
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KdfManager
@ -113,6 +112,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -1886,15 +1886,27 @@ class AuthRepositoryImpl(
val masterPassword = password ?: return null
val privateKey = loginResponse.privateKeyOrNull() ?: return null
val key = loginResponse.key ?: return null
val initUserCryptoMethod = loginResponse
.userDecryptionOptions
?.masterPasswordUnlock
?.let { masterPasswordUnlock ->
InitUserCryptoMethod.MasterPasswordUnlock(
password = masterPassword,
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
)
}
?: InitUserCryptoMethod.Password(
password = masterPassword,
userKey = key,
)
return unlockVault(
accountProfile = profile,
privateKey = privateKey,
securityState = loginResponse.accountKeys?.securityState?.securityState,
signingKey = loginResponse.accountKeys?.signatureKeyPair?.wrappedSigningKey,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
userKey = key,
),
initUserCryptoMethod = initUserCryptoMethod,
)
}
@ -2047,20 +2059,10 @@ class AuthRepositoryImpl(
initUserCryptoMethod: InitUserCryptoMethod,
): VaultUnlockResult {
val userId = accountProfile.userId
val kdfParams = (initUserCryptoMethod as? InitUserCryptoMethod.Password)
?.let {
accountProfile
.userDecryptionOptions
?.masterPasswordUnlock
?.kdf
?.toKdf()
}
?: accountProfile.toSdkParams()
return vaultRepository.unlockVault(
userId = userId,
email = accountProfile.email,
kdf = kdfParams,
kdf = accountProfile.toSdkParams(),
privateKey = privateKey,
signingKey = signingKey,
securityState = securityState,

View File

@ -219,22 +219,12 @@ class VaultLockManagerImpl(
initializeCryptoResult
.toVaultUnlockResult()
.also {
if (initUserCryptoMethod is InitUserCryptoMethod.Password) {
// Save the master password hash.
authSdkSource
.hashPassword(
email = email,
password = initUserCryptoMethod.password,
kdf = kdf,
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = userId,
passwordHash = passwordHash,
)
}
}
hashAndStoreMasterPassword(
initUserCryptoMethod = initUserCryptoMethod,
email = email,
kdf = kdf,
userId = userId,
)
if (it is VaultUnlockResult.Success) {
clearInvalidUnlockCount(userId = userId)
trustedDeviceManager
@ -257,6 +247,43 @@ class VaultLockManagerImpl(
.first()
}
/**
* Hashes a password and stores it as the master password hash for a given user.
*/
private suspend fun hashAndStoreMasterPassword(
initUserCryptoMethod: InitUserCryptoMethod,
email: String,
kdf: Kdf,
userId: String,
) {
if (initUserCryptoMethod is InitUserCryptoMethod.Password ||
initUserCryptoMethod is InitUserCryptoMethod.MasterPasswordUnlock
) {
val password = when (initUserCryptoMethod) {
is InitUserCryptoMethod.Password -> initUserCryptoMethod.password
is InitUserCryptoMethod.MasterPasswordUnlock -> initUserCryptoMethod.password
else -> throw IllegalStateException(
"Invalid initUserCryptoMethod ${initUserCryptoMethod.logTag}.",
)
}
// Save the master password hash.
authSdkSource
.hashPassword(
email = email,
password = password,
kdf = kdf,
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = userId,
passwordHash = passwordHash,
)
}
}
}
override suspend fun waitUntilUnlocked(userId: String) {
vaultUnlockDataStateFlow
.map { vaultUnlockDataList ->

View File

@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.vault.repository.util.logTag
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList
import kotlinx.coroutines.CoroutineScope
@ -346,22 +347,31 @@ class VaultRepositoryImpl(
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("User key"),
)
val activeAccount = authDiskSource.userState?.activeAccount
val initUserCryptoMethod = activeAccount
?.profile
?.userDecryptionOptions
?.masterPasswordUnlock
?.let { masterPasswordUnlock ->
InitUserCryptoMethod.MasterPasswordUnlock(
password = masterPassword,
masterPasswordUnlock = masterPasswordUnlock.toSdkMasterPasswordUnlock(),
)
}
?: InitUserCryptoMethod.Password(
password = masterPassword,
userKey = userKey,
)
return this
.unlockVaultForUser(
userId = userId,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
userKey = userKey,
),
initUserCryptoMethod = initUserCryptoMethod,
)
.also {
if (it is VaultUnlockResult.Success) {
deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
userKey = userKey,
),
initUserCryptoMethod = initUserCryptoMethod,
)
}
}

View File

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdf
/**
* Converts [MasterPasswordUnlockDataJson] to [MasterPasswordUnlockData]
*/
fun MasterPasswordUnlockDataJson.toSdkMasterPasswordUnlock(): MasterPasswordUnlockData =
MasterPasswordUnlockData(
kdf = kdf.toKdf(),
masterKeyWrappedUserKey = masterKeyWrappedUserKey,
salt = salt,
)

View File

@ -6987,79 +6987,6 @@ class AuthRepositoryTest {
)
}
@Test
fun `unlockVault uses user decryption options for KDF when init method is password`() =
runTest {
val successResponse = GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS
coEvery {
identityService.preLogin(email = EMAIL)
} returns PRE_LOGIN_SUCCESS.asSuccess()
coEvery {
identityService.getToken(
email = EMAIL,
authModel = IdentityTokenAuthModel.MasterPassword(
username = EMAIL,
password = PASSWORD_HASH,
),
uniqueAppId = UNIQUE_APP_ID,
)
} returns successResponse.asSuccess()
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_2.profile.toSdkParams(),
privateKey = successResponse.accountKeys!!
.publicKeyEncryptionKeyPair
.wrappedPrivateKey,
signingKey = successResponse.accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
securityState = successResponse.accountKeys
?.securityState
?.securityState,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = PASSWORD,
userKey = successResponse.key!!,
),
organizationKeys = null,
)
} returns VaultUnlockResult.Success
coEvery { vaultRepository.syncIfNecessary() } just runs
every {
GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1_WITH_DECRYPTION_OPTIONS
repository.login(email = EMAIL, password = PASSWORD)
coVerify {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_2.profile.toSdkParams(),
privateKey = successResponse.accountKeys!!
.publicKeyEncryptionKeyPair
.wrappedPrivateKey,
signingKey = successResponse.accountKeys
?.signatureKeyPair
?.wrappedSigningKey,
securityState = successResponse.accountKeys
?.securityState
?.securityState,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = PASSWORD,
userKey = successResponse.key!!,
),
organizationKeys = null,
)
vaultRepository.syncIfNecessary()
settingsRepository.storeUserHasLoggedInValue(userId = USER_ID_1)
}
}
companion object {
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),

View File

@ -9,10 +9,12 @@ import com.bitwarden.core.EnrollPinResponse
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@ -1001,6 +1003,7 @@ class VaultLockManagerTest {
request = InitOrgCryptoRequest(organizationKeys = organizationKeys),
)
trustedDeviceManager.trustThisDeviceIfNecessary(userId = USER_ID)
kdfManager.updateKdfToMinimumsIfNeeded(masterPassword)
}
}
@ -1764,6 +1767,107 @@ class VaultLockManagerTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `unlockVault with initUserCryptoMethod masterPasswordUnlock success should hash and store master password`() =
runTest {
val kdf = MOCK_PROFILE.toSdkParams()
val email = MOCK_PROFILE.email
val masterPassword = "drowssap"
val privateKey = "54321"
val organizationKeys = mapOf("orgId1" to "orgKey1")
val initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock(
password = masterPassword,
masterPasswordUnlock = MasterPasswordUnlockData(
kdf = mockk<Kdf>(relaxed = true),
masterKeyWrappedUserKey = "mockKey",
salt = "mockSalt",
),
)
coEvery {
vaultSdkSource.initializeCrypto(
userId = USER_ID,
request = InitUserCryptoRequest(
userId = USER_ID,
kdfParams = kdf,
email = email,
privateKey = privateKey,
method = initUserCryptoMethod,
signingKey = null,
securityState = null,
),
)
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = USER_ID,
request = InitOrgCryptoRequest(organizationKeys = organizationKeys),
)
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
trustedDeviceManager.trustThisDeviceIfNecessary(userId = USER_ID)
} returns false.asSuccess()
assertEquals(
emptyList<VaultUnlockData>(),
vaultLockManager.vaultUnlockDataStateFlow.value,
)
mutableVaultTimeoutStateFlow.value = VaultTimeout.ThirtyMinutes
fakeAuthDiskSource.storeUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = null,
)
val result = vaultLockManager.unlockVault(
userId = USER_ID,
email = email,
kdf = kdf,
privateKey = privateKey,
signingKey = null,
securityState = null,
initUserCryptoMethod = initUserCryptoMethod,
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",
)
coVerify(exactly = 1) {
vaultSdkSource.initializeCrypto(
userId = USER_ID,
request = InitUserCryptoRequest(
userId = USER_ID,
kdfParams = kdf,
email = email,
privateKey = privateKey,
method = initUserCryptoMethod,
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.

View File

@ -13,7 +13,9 @@ import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.network.model.CipherTypeJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.network.model.createMockCipher
import com.bitwarden.network.model.createMockFolder
import com.bitwarden.network.model.createMockOrganizationKeys
@ -28,6 +30,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
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.datasource.sdk.util.toKdfRequestModel
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
@ -53,6 +56,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.createVerificationCodeItem
import io.mockk.coEvery
import io.mockk.coVerify
@ -748,6 +752,132 @@ class VaultRepositoryTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithMasterPassword with masterPasswordUnlock data should use MasterPasswordUnlock method`() =
runTest {
val userId = "mockId-1"
val masterPassword = "mockPassword-1"
val masterPasswordUnlockData = MOCK_MASTER_PASSWORD_UNLOCK_DATA
.toSdkMasterPasswordUnlock()
val userState = MOCK_USER_STATE.copy(
accounts = mapOf(
"mockId-1" to MOCK_ACCOUNT.copy(
profile = MOCK_PROFILE.copy(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
),
),
),
)
fakeAuthDiskSource.storeUserKey(
userId = userId,
userKey = "mockKey-1",
)
fakeAuthDiskSource.userState = userState
fakeAuthDiskSource.storePrivateKey(userId = userId, privateKey = "mockPrivateKey-1")
coEvery {
vaultLockManager.unlockVault(
userId = userId,
email = "email",
kdf = MOCK_PROFILE.toSdkParams(),
privateKey = "mockPrivateKey-1",
signingKey = null,
securityState = null,
initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock(
password = masterPassword,
masterPasswordUnlock = masterPasswordUnlockData,
),
organizationKeys = null,
)
} returns VaultUnlockResult.Success
val result = vaultRepository.unlockVaultWithMasterPassword(
masterPassword = masterPassword,
)
assertEquals(VaultUnlockResult.Success, result)
coVerify {
vaultLockManager.unlockVault(
userId = userId,
email = "email",
kdf = MOCK_PROFILE.toSdkParams(),
privateKey = "mockPrivateKey-1",
signingKey = null,
securityState = null,
initUserCryptoMethod = InitUserCryptoMethod.MasterPasswordUnlock(
password = masterPassword,
masterPasswordUnlock = masterPasswordUnlockData,
),
organizationKeys = null,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithMasterPassword without masterPasswordUnlock data should use Password method`() =
runTest {
val userId = "mockId-1"
val masterPassword = "mockPassword-1"
val userKey = "mockUserKey-1"
val userState = MOCK_USER_STATE.copy(
accounts = mapOf(
"mockId-1" to MOCK_ACCOUNT.copy(
profile = MOCK_PROFILE.copy(
userDecryptionOptions = null,
),
),
),
)
fakeAuthDiskSource.userState = userState
fakeAuthDiskSource.storePrivateKey(userId = userId, privateKey = "mockPrivateKey-1")
fakeAuthDiskSource.storeUserKey(userId = userId, userKey = userKey)
coEvery {
vaultLockManager.unlockVault(
userId = userId,
email = "email",
kdf = MOCK_PROFILE.toSdkParams(),
privateKey = "mockPrivateKey-1",
signingKey = null,
securityState = null,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
userKey = userKey,
),
organizationKeys = null,
)
} returns VaultUnlockResult.Success
val result = vaultRepository.unlockVaultWithMasterPassword(
masterPassword = masterPassword,
)
assertEquals(VaultUnlockResult.Success, result)
coVerify {
vaultLockManager.unlockVault(
userId = userId,
email = "email",
kdf = MOCK_PROFILE.toSdkParams(),
privateKey = "mockPrivateKey-1",
signingKey = null,
securityState = null,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
userKey = userKey,
),
organizationKeys = null,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithMasterPassword with VaultLockManager non-Success should unlock for the current user and return the error`() =
@ -1615,3 +1745,9 @@ private val MOCK_USER_STATE = UserStateJson(
"mockId-1" to MOCK_ACCOUNT,
),
)
private val MOCK_MASTER_PASSWORD_UNLOCK_DATA = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = MOCK_ACCOUNT.profile.toSdkParams().toKdfRequestModel(),
masterKeyWrappedUserKey = "masterKeyWrappedUserKeyMock",
)

View File

@ -1,22 +1,29 @@
package com.bitwarden.network.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
/**
* Represents the data used to create the kdf settings.
*/
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class KdfJson(
@SerialName("KdfType")
@SerialName("kdfType")
@JsonNames("KdfType")
val kdfType: KdfTypeJson,
@SerialName("Iterations")
@SerialName("iterations")
@JsonNames("Iterations")
val iterations: Int,
@SerialName("Memory")
@SerialName("memory")
@JsonNames("Memory")
val memory: Int?,
@SerialName("Parallelism")
@SerialName("parallelism")
@JsonNames("Parallelism")
val parallelism: Int?,
)

View File

@ -11,15 +11,17 @@ import kotlinx.serialization.json.JsonNames
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class MasterPasswordUnlockDataJson(
@SerialName("Salt")
@SerialName("salt")
@JsonNames("Salt")
val salt: String,
@SerialName("Kdf")
@SerialName("kdf")
@JsonNames("Kdf")
val kdf: KdfJson,
// TODO: PM-26397 this was done due to naming inconsistency server side,
// should be cleaned up when server side is updated
@SerialName("MasterKeyWrappedUserKey")
@JsonNames("MasterKeyEncryptedUserKey")
@SerialName("masterKeyWrappedUserKey")
@JsonNames("masterKeyEncryptedUserKey", "MasterKeyEncryptedUserKey")
val masterKeyWrappedUserKey: String,
)

View File

@ -49,7 +49,7 @@ data class SyncResponseJson(
@SerialName("sends")
val sends: List<Send>?,
@SerialName("UserDecryption")
@SerialName("userDecryption")
val userDecryption: UserDecryptionJson?,
) {
/**

View File

@ -8,6 +8,6 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class UserDecryptionJson(
@SerialName("MasterPasswordUnlock")
@SerialName("masterPasswordUnlock")
val masterPasswordUnlock: MasterPasswordUnlockDataJson?,
)