mirror of
https://github.com/bitwarden/android.git
synced 2025-02-18 04:50:01 +03:00
BIT-2355: Check to see if a cipher needs to be migrated when encrypting the cipher (#1442)
This commit is contained in:
parent
6aee2acf9e
commit
0533ef61ff
3 changed files with 145 additions and 38 deletions
|
@ -147,6 +147,10 @@ interface VaultSdkSource {
|
||||||
*
|
*
|
||||||
* This should only be called after a successful call to [initializeCrypto] for the associated
|
* This should only be called after a successful call to [initializeCrypto] for the associated
|
||||||
* user.
|
* user.
|
||||||
|
*
|
||||||
|
* Note that this function will always add a [CipherView.key] to a cipher if it is missing,
|
||||||
|
* it is important to ensure that any [CipherView] being encrypted is pushed to the cloud if
|
||||||
|
* it was previously missing a `key` to ensure synchronization and prevent data-loss.
|
||||||
*/
|
*/
|
||||||
suspend fun encryptCipher(
|
suspend fun encryptCipher(
|
||||||
userId: String,
|
userId: String,
|
||||||
|
|
|
@ -6,6 +6,7 @@ import com.bitwarden.core.Cipher
|
||||||
import com.bitwarden.core.CipherView
|
import com.bitwarden.core.CipherView
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||||
|
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||||
import com.x8bit.bitwarden.data.platform.util.flatMap
|
import com.x8bit.bitwarden.data.platform.util.flatMap
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRequest
|
import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRequest
|
||||||
|
@ -105,20 +106,24 @@ class CipherManagerImpl(
|
||||||
cipherView: CipherView,
|
cipherView: CipherView,
|
||||||
): DeleteCipherResult {
|
): DeleteCipherResult {
|
||||||
val userId = activeUserId ?: return DeleteCipherResult.Error
|
val userId = activeUserId ?: return DeleteCipherResult.Error
|
||||||
return ciphersService
|
return cipherView
|
||||||
.softDeleteCipher(cipherId = cipherId)
|
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
|
||||||
|
.flatMap { cipher ->
|
||||||
|
ciphersService
|
||||||
|
.softDeleteCipher(cipherId = cipherId)
|
||||||
|
.flatMap { vaultSdkSource.decryptCipher(userId = userId, cipher = cipher) }
|
||||||
|
}
|
||||||
|
.flatMap {
|
||||||
|
vaultSdkSource.encryptCipher(
|
||||||
|
userId = userId,
|
||||||
|
cipherView = it.copy(deletedDate = clock.instant()),
|
||||||
|
)
|
||||||
|
}
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
vaultSdkSource
|
vaultDiskSource.saveCipher(
|
||||||
.encryptCipher(
|
userId = userId,
|
||||||
userId = userId,
|
cipher = it.toEncryptedNetworkCipherResponse(),
|
||||||
cipherView = cipherView.copy(deletedDate = clock.instant()),
|
)
|
||||||
)
|
|
||||||
.onSuccess { cipher ->
|
|
||||||
vaultDiskSource.saveCipher(
|
|
||||||
userId = userId,
|
|
||||||
cipher = cipher.toEncryptedNetworkCipherResponse(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.fold(
|
.fold(
|
||||||
onSuccess = { DeleteCipherResult.Success },
|
onSuccess = { DeleteCipherResult.Success },
|
||||||
|
@ -153,14 +158,13 @@ class CipherManagerImpl(
|
||||||
attachmentId = attachmentId,
|
attachmentId = attachmentId,
|
||||||
)
|
)
|
||||||
.flatMap {
|
.flatMap {
|
||||||
vaultSdkSource.encryptCipher(
|
cipherView
|
||||||
userId = userId,
|
.copy(
|
||||||
cipherView = cipherView.copy(
|
|
||||||
attachments = cipherView.attachments?.mapNotNull {
|
attachments = cipherView.attachments?.mapNotNull {
|
||||||
if (it.id == attachmentId) null else it
|
if (it.id == attachmentId) null else it
|
||||||
},
|
},
|
||||||
),
|
)
|
||||||
)
|
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
|
||||||
}
|
}
|
||||||
.onSuccess { cipher ->
|
.onSuccess { cipher ->
|
||||||
vaultDiskSource.saveCipher(
|
vaultDiskSource.saveCipher(
|
||||||
|
@ -320,10 +324,10 @@ class CipherManagerImpl(
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
key = null,
|
key = null,
|
||||||
)
|
)
|
||||||
return vaultSdkSource
|
return cipherView
|
||||||
.encryptCipher(
|
.encryptCipherAndCheckForMigration(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
cipherView = cipherView,
|
cipherId = requireNotNull(cipherView.id),
|
||||||
)
|
)
|
||||||
.flatMap { cipher ->
|
.flatMap { cipher ->
|
||||||
fileManager
|
fileManager
|
||||||
|
@ -400,10 +404,10 @@ class CipherManagerImpl(
|
||||||
): Result<File> {
|
): Result<File> {
|
||||||
val userId = activeUserId ?: return IllegalStateException("No active user").asFailure()
|
val userId = activeUserId ?: return IllegalStateException("No active user").asFailure()
|
||||||
|
|
||||||
val cipher = vaultSdkSource
|
val cipher = cipherView
|
||||||
.encryptCipher(
|
.encryptCipherAndCheckForMigration(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
cipherView = cipherView,
|
cipherId = requireNotNull(cipherView.id),
|
||||||
)
|
)
|
||||||
.fold(
|
.fold(
|
||||||
onSuccess = { it },
|
onSuccess = { it },
|
||||||
|
@ -443,4 +447,36 @@ class CipherManagerImpl(
|
||||||
.onFailure { fileManager.delete(encryptedFile) }
|
.onFailure { fileManager.delete(encryptedFile) }
|
||||||
.map { decryptedFile }
|
.map { decryptedFile }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper method to check if the [CipherView] needs to be migrated when you encrypt it.
|
||||||
|
*/
|
||||||
|
private suspend fun CipherView.encryptCipherAndCheckForMigration(
|
||||||
|
userId: String,
|
||||||
|
cipherId: String,
|
||||||
|
): Result<Cipher> =
|
||||||
|
if (this.key == null) {
|
||||||
|
vaultSdkSource
|
||||||
|
.encryptCipher(userId = userId, cipherView = this)
|
||||||
|
.flatMap {
|
||||||
|
ciphersService.updateCipher(
|
||||||
|
cipherId = cipherId,
|
||||||
|
body = it.toEncryptedNetworkCipher(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.flatMap { response ->
|
||||||
|
when (response) {
|
||||||
|
is UpdateCipherResponseJson.Invalid -> {
|
||||||
|
IllegalStateException(response.message).asFailure()
|
||||||
|
}
|
||||||
|
|
||||||
|
is UpdateCipherResponseJson.Success -> {
|
||||||
|
vaultDiskSource.saveCipher(userId = userId, cipher = response.cipher)
|
||||||
|
response.cipher.toEncryptedSdkCipher().asSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vaultSdkSource.encryptCipher(userId = userId, cipherView = this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
|
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
|
||||||
|
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
|
||||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse
|
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse
|
||||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
|
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
@ -69,7 +70,7 @@ class CipherManagerTest {
|
||||||
private val vaultDiskSource: VaultDiskSource = mockk()
|
private val vaultDiskSource: VaultDiskSource = mockk()
|
||||||
private val vaultSdkSource: VaultSdkSource = mockk()
|
private val vaultSdkSource: VaultSdkSource = mockk()
|
||||||
|
|
||||||
private val cipherManager: CipherManagerImpl = CipherManagerImpl(
|
private val cipherManager: CipherManager = CipherManagerImpl(
|
||||||
ciphersService = ciphersService,
|
ciphersService = ciphersService,
|
||||||
vaultDiskSource = vaultDiskSource,
|
vaultDiskSource = vaultDiskSource,
|
||||||
vaultSdkSource = vaultSdkSource,
|
vaultSdkSource = vaultSdkSource,
|
||||||
|
@ -464,14 +465,20 @@ class CipherManagerTest {
|
||||||
fun `softDeleteCipher with ciphersService softDeleteCipher failure should return DeleteCipherResult Error`() =
|
fun `softDeleteCipher with ciphersService softDeleteCipher failure should return DeleteCipherResult Error`() =
|
||||||
runTest {
|
runTest {
|
||||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
|
val userId = MOCK_USER_STATE.activeUserId
|
||||||
val cipherId = "mockId-1"
|
val cipherId = "mockId-1"
|
||||||
|
val cipherView = createMockCipherView(number = 1)
|
||||||
|
val cipher = createMockSdkCipher(number = 1)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
coEvery {
|
coEvery {
|
||||||
ciphersService.softDeleteCipher(cipherId = cipherId)
|
ciphersService.softDeleteCipher(cipherId = cipherId)
|
||||||
} returns Throwable("Fail").asFailure()
|
} returns Throwable("Fail").asFailure()
|
||||||
|
|
||||||
val result = cipherManager.softDeleteCipher(
|
val result = cipherManager.softDeleteCipher(
|
||||||
cipherId = cipherId,
|
cipherId = cipherId,
|
||||||
cipherView = createMockCipherView(number = 1),
|
cipherView = cipherView,
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(DeleteCipherResult.Error, result)
|
assertEquals(DeleteCipherResult.Error, result)
|
||||||
|
@ -481,30 +488,31 @@ class CipherManagerTest {
|
||||||
@Test
|
@Test
|
||||||
fun `softDeleteCipher with ciphersService softDeleteCipher success should return DeleteCipherResult success`() =
|
fun `softDeleteCipher with ciphersService softDeleteCipher success should return DeleteCipherResult success`() =
|
||||||
runTest {
|
runTest {
|
||||||
mockkStatic(Cipher::toEncryptedNetworkCipherResponse)
|
|
||||||
every {
|
|
||||||
createMockSdkCipher(number = 1, clock = clock).toEncryptedNetworkCipherResponse()
|
|
||||||
} returns createMockCipher(number = 1)
|
|
||||||
val fixedInstant = Instant.parse("2023-10-27T12:00:00Z")
|
val fixedInstant = Instant.parse("2023-10-27T12:00:00Z")
|
||||||
val userId = "mockId-1"
|
val userId = "mockId-1"
|
||||||
val cipherId = "mockId-1"
|
val cipherId = "mockId-1"
|
||||||
|
val cipher = createMockSdkCipher(number = 1, clock = clock)
|
||||||
|
val cipherView = createMockCipherView(number = 1)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
coEvery {
|
coEvery {
|
||||||
vaultSdkSource.encryptCipher(
|
vaultSdkSource.encryptCipher(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
cipherView = createMockCipherView(number = 1).copy(deletedDate = fixedInstant),
|
cipherView = cipherView.copy(deletedDate = fixedInstant),
|
||||||
)
|
)
|
||||||
} returns createMockSdkCipher(number = 1, clock = clock).asSuccess()
|
} returns cipher.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.decryptCipher(userId = userId, cipher = cipher)
|
||||||
|
} returns cipherView.asSuccess()
|
||||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
coEvery { ciphersService.softDeleteCipher(cipherId = cipherId) } returns Unit.asSuccess()
|
coEvery { ciphersService.softDeleteCipher(cipherId = cipherId) } returns Unit.asSuccess()
|
||||||
coEvery {
|
coEvery {
|
||||||
vaultDiskSource.saveCipher(
|
vaultDiskSource.saveCipher(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
cipher = createMockCipher(number = 1),
|
cipher = cipher.toEncryptedNetworkCipherResponse(),
|
||||||
)
|
)
|
||||||
} just runs
|
} just runs
|
||||||
val cipherView = createMockCipherView(number = 1)
|
|
||||||
mockkStatic(Instant::class)
|
|
||||||
every { Instant.now() } returns fixedInstant
|
|
||||||
|
|
||||||
val result = cipherManager.softDeleteCipher(
|
val result = cipherManager.softDeleteCipher(
|
||||||
cipherId = cipherId,
|
cipherId = cipherId,
|
||||||
|
@ -512,8 +520,67 @@ class CipherManagerTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(DeleteCipherResult.Success, result)
|
assertEquals(DeleteCipherResult.Success, result)
|
||||||
unmockkStatic(Instant::class)
|
}
|
||||||
unmockkStatic(Cipher::toEncryptedNetworkCipherResponse)
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `softDeleteCipher with cipher migration success should return DeleteCipherResult success`() =
|
||||||
|
runTest {
|
||||||
|
val fixedInstant = Instant.parse("2023-10-27T12:00:00Z")
|
||||||
|
val userId = "mockId-1"
|
||||||
|
val cipherId = "mockId-1"
|
||||||
|
val cipher = createMockSdkCipher(number = 1, clock = clock)
|
||||||
|
val cipherView = createMockCipherView(number = 1).copy(key = null)
|
||||||
|
val networkCipher = createMockCipher(number = 1).copy(key = null)
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(userId = userId, cipherView = cipherView)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
ciphersService.updateCipher(
|
||||||
|
cipherId = cipherId,
|
||||||
|
body = cipher.toEncryptedNetworkCipher(),
|
||||||
|
)
|
||||||
|
} returns UpdateCipherResponseJson.Success(networkCipher).asSuccess()
|
||||||
|
coEvery {
|
||||||
|
vaultDiskSource.saveCipher(userId = userId, cipher = networkCipher)
|
||||||
|
} just runs
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.decryptCipher(
|
||||||
|
userId = userId,
|
||||||
|
cipher = networkCipher.toEncryptedSdkCipher(),
|
||||||
|
)
|
||||||
|
} returns cipherView.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.encryptCipher(
|
||||||
|
userId = userId,
|
||||||
|
cipherView = cipherView.copy(deletedDate = fixedInstant),
|
||||||
|
)
|
||||||
|
} returns cipher.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.decryptCipher(userId = userId, cipher = cipher)
|
||||||
|
} returns cipherView.asSuccess()
|
||||||
|
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||||
|
coEvery { ciphersService.softDeleteCipher(cipherId = cipherId) } returns Unit.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
vaultDiskSource.saveCipher(
|
||||||
|
userId = userId,
|
||||||
|
cipher = cipher.toEncryptedNetworkCipherResponse(),
|
||||||
|
)
|
||||||
|
} just runs
|
||||||
|
|
||||||
|
val result = cipherManager.softDeleteCipher(
|
||||||
|
cipherId = cipherId,
|
||||||
|
cipherView = cipherView,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(DeleteCipherResult.Success, result)
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
ciphersService.updateCipher(
|
||||||
|
cipherId = cipherId,
|
||||||
|
body = cipher.toEncryptedNetworkCipher(),
|
||||||
|
)
|
||||||
|
vaultDiskSource.saveCipher(userId = userId, cipher = networkCipher)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
Loading…
Add table
Reference in a new issue