Compare commits

...

1 Commits

Author SHA1 Message Date
David Perez
d37fefc4f8 PM-39006: Update the setPassword flow for TDE users 2026-06-12 16:45:13 -05:00
4 changed files with 360 additions and 285 deletions

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
@@ -1145,10 +1146,10 @@ class AuthRepositoryImpl(
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
): SetPasswordResult = userStateManager.userStateTransaction {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return SetPasswordResult.Error(error = NoActiveUserException())
return when (profile.forcePasswordResetReason) {
?: return@userStateTransaction SetPasswordResult.Error(error = NoActiveUserException())
return@userStateTransaction when (profile.forcePasswordResetReason) {
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> {
setUpdatedPassword(
profile = profile,
@@ -1196,30 +1197,25 @@ class AuthRepositoryImpl(
keys = null,
),
)
.map { response.passwordHash }
.map { response }
}
.flatMap { masterPasswordHash ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = masterPasswordHash,
)
}
is VaultUnlockError -> {
(result.error ?: IllegalStateException("Failed to unlock vault"))
.asFailure()
}
}
}
.onSuccess {
.onSuccess { response ->
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
masterPasswordUnlock = MasterPasswordUnlockData(
kdf = profile.toSdkParams(),
masterKeyWrappedUserKey = response.newKey,
salt = profile.email,
),
)
this.organizationIdentifier = null
}
.flatMap { response ->
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = response.passwordHash,
)
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
@@ -1326,16 +1322,26 @@ class AuthRepositoryImpl(
privateKey = response.keys.private,
),
)
authDiskSource.userState = authDiskSource
.userState
?.toUserStateJsonWithPassword(
masterPasswordUnlock = MasterPasswordUnlockData(
kdf = profile.toSdkParams(),
masterKeyWrappedUserKey = response.encryptedUserKey,
salt = profile.email,
),
)
this.organizationIdentifier = null
}
.map { response.masterPasswordHash }
.map { response }
}
.flatMap { masterPasswordHash ->
.flatMap { response ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = masterPasswordHash,
passwordHash = response.masterPasswordHash,
)
}
@@ -1345,12 +1351,6 @@ class AuthRepositoryImpl(
}
}
}
.onSuccess {
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
)
this.organizationIdentifier = null
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },

View File

@@ -10,6 +10,7 @@ import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
@@ -84,10 +85,17 @@ fun UserStateJson.toUpdatedUserStateJson(
}
?: profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = null)
?.copy(
hasMasterPassword = false,
masterPasswordUnlock = null,
)
val forcePasswordResetReason = syncProfile.getForcePasswordResetReason(
userDecryptionOptions = userDecryptionOptions,
previousForcePasswordResetReason = profile.forcePasswordResetReason,
)
val updatedProfile = profile
.copy(
forcePasswordResetReason = forcePasswordResetReason,
avatarColorHex = syncProfile.avatarColor,
stamp = syncProfile.securityStamp,
hasPremiumPersonally = syncProfile.isPremium,
@@ -95,24 +103,31 @@ fun UserStateJson.toUpdatedUserStateJson(
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
kdfType = masterPasswordUnlockKdf?.kdfType
?: profile.kdfType,
kdfIterations = masterPasswordUnlockKdf?.iterations
?: profile.kdfIterations,
kdfMemory = masterPasswordUnlockKdf?.memory
?: profile.kdfMemory,
kdfParallelism = masterPasswordUnlockKdf?.parallelism
?: profile.kdfParallelism,
kdfType = masterPasswordUnlockKdf?.kdfType ?: profile.kdfType,
kdfIterations = masterPasswordUnlockKdf?.iterations ?: profile.kdfIterations,
kdfMemory = masterPasswordUnlockKdf?.memory ?: profile.kdfMemory,
kdfParallelism = masterPasswordUnlockKdf?.parallelism ?: profile.kdfParallelism,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this
.copy(
accounts = accounts
.toMutableMap()
.apply {
replace(userId, updatedAccount)
},
)
return this.copy(accounts = accounts.toMutableMap().apply { replace(userId, updatedAccount) })
}
private fun SyncResponseJson.Profile.getForcePasswordResetReason(
userDecryptionOptions: UserDecryptionOptionsJson?,
previousForcePasswordResetReason: ForcePasswordResetReason?,
): ForcePasswordResetReason? {
val hasManageResetPasswordPermission = this.organizations.orEmpty().any {
it.type == OrganizationType.OWNER ||
it.type == OrganizationType.ADMIN ||
it.permissions.shouldManageResetPassword
}
return ForcePasswordResetReason
.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION
.takeIf {
userDecryptionOptions?.hasMasterPassword == false &&
hasManageResetPasswordPermission
}
?: previousForcePasswordResetReason
}
/**
@@ -120,20 +135,16 @@ fun UserStateJson.toUpdatedUserStateJson(
* their password.
*/
fun UserStateJson.toUserStateJsonWithPassword(
masterPasswordUnlock: MasterPasswordUnlockData?,
masterPasswordUnlock: MasterPasswordUnlockData,
): UserStateJson {
val account = this.activeAccount
val profile = account.profile
val userDecryptionOptions = profile.userDecryptionOptions
val masterPasswordUnlockJson = masterPasswordUnlock
?.let {
MasterPasswordUnlockDataJson(
salt = it.salt,
kdf = it.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = it.masterKeyWrappedUserKey,
)
}
?: userDecryptionOptions?.masterPasswordUnlock
val masterPasswordUnlockJson = MasterPasswordUnlockDataJson(
salt = masterPasswordUnlock.salt,
kdf = masterPasswordUnlock.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = masterPasswordUnlock.masterKeyWrappedUserKey,
)
val updatedProfile = profile
.copy(
forcePasswordResetReason = null,

View File

@@ -5795,9 +5795,6 @@ class AuthRepositoryTest {
userId = profile.userId,
)
} returns resetPasswordKey.asSuccess()
coEvery {
vaultRepository.unlockVaultWithMasterPassword(password)
} returns VaultUnlockResult.Success
val result = repository.setPassword(
organizationIdentifier = organizationIdentifier,
@@ -5822,7 +5819,6 @@ class AuthRepositoryTest {
passwordHash = passwordHash,
resetPasswordKey = resetPasswordKey,
)
vaultRepository.unlockVaultWithMasterPassword(password)
vaultSdkSource.getResetPasswordKey(
orgPublicKey = publicOrgKey,
userId = profile.userId,
@@ -7481,7 +7477,7 @@ class AuthRepositoryTest {
masterPasswordUnlock = MasterPasswordUnlockDataJson(
kdf = BASE_PROFILE_1.toSdkParams().toKdfRequestModel(),
masterKeyWrappedUserKey = ENCRYPTED_USER_KEY,
salt = "mockSalt",
salt = EMAIL,
),
),
)
@@ -7533,7 +7529,7 @@ class AuthRepositoryTest {
masterPasswordUnlock = MasterPasswordUnlockDataJson(
kdf = BASE_PROFILE_1.toSdkParams().toKdfRequestModel(),
masterKeyWrappedUserKey = ENCRYPTED_USER_KEY,
salt = "mockSalt",
salt = EMAIL,
),
),
),

View File

@@ -9,10 +9,13 @@ import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorUserDecryptionOptionsJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.UserDecryptionJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.network.model.createMockOrganizationNetwork
import com.bitwarden.network.model.createMockPermissions
import com.bitwarden.network.model.createMockProfile
import com.bitwarden.network.model.createMockSyncResponse
import com.bitwarden.policies.PolicyType
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@@ -234,36 +237,36 @@ class UserStateJsonExtensionsTest {
)
val orgOnlyResult = originalState.toUpdatedUserStateJson(
syncResponse = mockk {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "color"
every { securityStamp } returns "stamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "color",
securityStamp = "stamp",
isPremium = false,
isPremiumFromOrganization = true,
),
userDecryption = null,
),
)
val orgOnlyProfile = orgOnlyResult.accounts.getValue("activeUserId").profile
assertEquals(false, orgOnlyProfile.hasPremiumPersonally)
assertEquals(true, orgOnlyProfile.hasPremiumFromOrganization)
val personalOnlyResult = originalState.toUpdatedUserStateJson(
syncResponse = mockk {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "color"
every { securityStamp } returns "stamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "color",
securityStamp = "stamp",
isPremium = true,
isPremiumFromOrganization = false,
),
userDecryption = null,
),
)
val personalOnlyProfile = personalOnlyResult.accounts.getValue("activeUserId").profile
assertEquals(true, personalOnlyProfile.hasPremiumPersonally)
@@ -398,74 +401,20 @@ class UserStateJsonExtensionsTest {
),
)
.toUpdatedUserStateJson(
syncResponse = mockk {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "avatarColor"
every { securityStamp } returns "securityStamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
),
)
}
@Suppress("MaxLineLength")
@Test
fun `toUserStateJsonWithPassword should update active account to set hasMasterPassword and clear forcePasswordResetReason`() {
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = true,
hasPremiumFromOrganization = null,
forcePasswordResetReason = ForcePasswordResetReason
.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = null,
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = mockk(),
settings = mockk(),
)
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
forcePasswordResetReason = null,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "avatarColor",
securityStamp = "securityStamp",
isPremium = true,
isPremiumFromOrganization = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = null,
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount,
),
)
.toUserStateJsonWithPassword(masterPasswordUnlock = null),
)
}
@@ -536,71 +485,6 @@ class UserStateJsonExtensionsTest {
)
}
@Test
fun `toUserStateJsonWithPassword should preserve values of userDecryptionOptions`() {
val keyConnectorOptionsJson = KeyConnectorUserDecryptionOptionsJson("key")
val trustedDeviceOptionsJson = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasManageResetPasswordPermission = true,
)
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = true,
hasPremiumFromOrganization = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
masterPasswordUnlock = null,
),
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = mockk(),
settings = mockk(),
)
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
masterPasswordUnlock = null,
),
),
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount,
),
)
.toUserStateJsonWithPassword(masterPasswordUnlock = null),
)
}
@Test
fun `toUserState should return the correct UserState for an unlocked vault`() {
val expectedCreationDate = Instant.parse("2024-06-15T10:30:00Z")
@@ -1993,20 +1877,22 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = mockk<SyncResponseJson>(relaxed = true) {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "avatarColor"
every { securityStamp } returns "securityStamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns true
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns UserDecryptionJson(
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "avatarColor",
securityStamp = "securityStamp",
isPremium = false,
isPremiumFromOrganization = false,
isTwoFactorEnabled = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
)
}
),
)
assertEquals(
UserStateJson(
@@ -2082,20 +1968,22 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "newAvatarColor"
every { securityStamp } returns "newSecurityStamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns true
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns UserDecryptionJson(
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "newAvatarColor",
securityStamp = "newSecurityStamp",
isPremium = true,
isPremiumFromOrganization = false,
isTwoFactorEnabled = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
)
}
),
)
assertEquals(
UserStateJson(
@@ -2129,7 +2017,7 @@ class UserStateJsonExtensionsTest {
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should update existing UserDecryptionOptionsJson when syncResponse has no userDecryption`() {
fun `toUpdatedUserStateJson should clear hasMasterPassword and masterPasswordUnlock when syncResponse has no userDecryption`() {
val keyConnectorOptions = KeyConnectorUserDecryptionOptionsJson("keyConnectorUrl")
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
@@ -2165,18 +2053,20 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "updatedAvatarColor"
every { securityStamp } returns "updatedSecurityStamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns null
}
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "updatedAvatarColor",
securityStamp = "updatedSecurityStamp",
isPremium = false,
isPremiumFromOrganization = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
organizations = emptyList(),
),
userDecryption = null,
)
assertEquals(
UserStateJson(
@@ -2191,7 +2081,7 @@ class UserStateJsonExtensionsTest {
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = keyConnectorOptions,
masterPasswordUnlock = null,
@@ -2236,31 +2126,28 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns null
every { securityStamp } returns null
every { isPremium } returns false
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
val updatedKdf = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = DEFAULT_PBKDF2_ITERATIONS,
memory = null,
parallelism = null,
)
val updatedMasterPasswordUnlock = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = updatedKdf,
masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey",
)
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = updatedMasterPasswordUnlock,
)
}
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = null,
securityStamp = null,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = UserDecryptionJson(
masterPasswordUnlock = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = DEFAULT_PBKDF2_ITERATIONS,
memory = null,
parallelism = null,
),
masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey",
),
),
)
assertEquals(
UserStateJson(
@@ -2293,6 +2180,137 @@ class UserStateJsonExtensionsTest {
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should set forcePasswordResetReason when user without master password is an organization admin`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = TDE_USER_DECRYPTION_OPTIONS,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.ADMIN,
),
),
),
userDecryption = null,
),
)
assertEquals(
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should set forcePasswordResetReason when user without master password has reset password permission`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = TDE_USER_DECRYPTION_OPTIONS,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.USER,
permissions = createMockPermissions(
shouldManageResetPassword = true,
),
),
),
),
userDecryption = UserDecryptionJson(masterPasswordUnlock = null),
),
)
assertEquals(
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should not set forcePasswordResetReason when user without master password lacks reset password permission`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = TDE_USER_DECRYPTION_OPTIONS,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.USER,
),
),
),
userDecryption = null,
),
)
assertEquals(
null,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should preserve previous forcePasswordResetReason when user has a master password`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
forcePasswordResetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.OWNER,
),
),
),
userDecryption = UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
),
)
assertEquals(
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
fun `toUserStateJsonKdfUpdatedMinimums should update KDF settings to minimum values`() {
val originalProfile = AccountJson.Profile(
@@ -2494,3 +2512,53 @@ private val MOCK_MASTER_PASSWORD_UNLOCK_DATA = MasterPasswordUnlockDataJson(
),
masterKeyWrappedUserKey = "masterKeyWrappedUserKeyMock",
)
private val TDE_USER_DECRYPTION_OPTIONS = UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = false,
hasManageResetPasswordPermission = false,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
/**
* Creates a [UserStateJson] with a single "activeUserId" account using the given
* [userDecryptionOptions] and [forcePasswordResetReason].
*/
private fun createUserStateWithDecryptionOptions(
userDecryptionOptions: UserDecryptionOptionsJson?,
forcePasswordResetReason: ForcePasswordResetReason? = null,
): UserStateJson =
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to AccountJson(
profile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = true,
hasPremiumFromOrganization = null,
forcePasswordResetReason = forcePasswordResetReason,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = userDecryptionOptions,
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
tokens = null,
settings = AccountJson.Settings(environmentUrlData = null),
),
),
)