Skip to content

Commit 0d18252

Browse files
BIT-2408: Migrate attachments without keys when moving a cipher to an organization (#674)
1 parent 574e1d3 commit 0d18252

File tree

2 files changed

+120
-5
lines changed

2 files changed

+120
-5
lines changed

BitwardenShared/Core/Vault/Repositories/VaultRepository.swift

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,37 @@ class DefaultVaultRepository { // swiftlint:disable:this type_body_length
392392
return cipher
393393
}
394394

395+
/// Downloads, re-encrypts, and re-uploads an attachment without an attachment key so that it
396+
/// can be shared to an organization.
397+
///
398+
/// - Parameters:
399+
/// - attachment: The attachment that will be shared with the organization.
400+
/// - cipher: The cipher containing the attachment.
401+
/// - Returns: The updated attachment with an attachment key that can be moved into the organization.
402+
///
403+
private func fixCipherAttachment(
404+
_ attachment: AttachmentView,
405+
cipher: CipherView
406+
) async throws -> CipherView {
407+
guard let downloadUrl = try await downloadAttachment(attachment, cipher: cipher) else {
408+
throw BitwardenError.dataError("Unable to download attachment")
409+
}
410+
411+
guard let cipherId = cipher.id else { throw CipherAPIServiceError.updateMissingId }
412+
guard let fileName = attachment.fileName else { throw BitwardenError.dataError("Missing filename") }
413+
414+
let attachmentData = try Data(contentsOf: downloadUrl)
415+
var updatedCipher = try await saveAttachment(cipherView: cipher, fileData: attachmentData, fileName: fileName)
416+
try FileManager.default.removeItem(at: downloadUrl)
417+
418+
if let attachmentId = attachment.id,
419+
let cipher = try await deleteAttachment(withId: attachmentId, cipherId: cipherId) {
420+
updatedCipher = cipher
421+
}
422+
423+
return updatedCipher
424+
}
425+
395426
/// Returns a list of `VaultListItem`s for the folders within a nested tree. By default, this
396427
/// will return the list items for the folders at the root of the tree. Specifying a
397428
/// `nestedFolderId` will return the list items for the children of the folder with the
@@ -1080,15 +1111,32 @@ extension DefaultVaultRepository: VaultRepository {
10801111
return try await clientService.vault().ciphers().decrypt(cipher: updatedCipher)
10811112
}
10821113

1083-
func shareCipher(_ cipher: CipherView, newOrganizationId: String, newCollectionIds: [String]) async throws {
1114+
func shareCipher(_ cipherView: CipherView, newOrganizationId: String, newCollectionIds: [String]) async throws {
1115+
// Ensure the cipher has a cipher key.
1116+
let encryptedCipher = try await encryptAndUpdateCipher(cipherView)
1117+
var cipherView = try await clientService.vault().ciphers().decrypt(cipher: encryptedCipher)
1118+
1119+
if let attachments = cipherView.attachments {
1120+
for attachment in attachments where attachment.key == nil {
1121+
// When moving a cipher to an organization, any attachments without an encryption
1122+
// key need to be re-encrypted with an attachment key.
1123+
cipherView = try await fixCipherAttachment(
1124+
attachment,
1125+
cipher: cipherView
1126+
)
1127+
}
1128+
}
1129+
10841130
let organizationCipher = try await clientService.vault().ciphers()
10851131
.moveToOrganization(
1086-
cipher: cipher,
1132+
cipher: cipherView,
10871133
organizationId: newOrganizationId
10881134
)
10891135
.update(collectionIds: newCollectionIds) // The SDK updates the cipher's organization ID.
1090-
let encryptedCipher = try await clientService.vault().ciphers().encrypt(cipherView: organizationCipher)
1091-
try await cipherService.shareCipherWithServer(encryptedCipher)
1136+
1137+
let encryptedOrganizationCipher = try await clientService.vault().ciphers()
1138+
.encrypt(cipherView: organizationCipher)
1139+
try await cipherService.shareCipherWithServer(encryptedOrganizationCipher)
10921140
}
10931141

10941142
func shouldShowUnassignedCiphersAlert() async -> Bool {

BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1037,13 +1037,80 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b
10371037
let updatedCipher = cipher.update(collectionIds: ["6", "7"])
10381038

10391039
XCTAssertEqual(cipherService.shareCipherWithServerCiphers, [Cipher(cipherView: updatedCipher)])
1040-
XCTAssertEqual(clientCiphers.encryptedCiphers, [updatedCipher])
1040+
XCTAssertEqual(clientCiphers.encryptedCiphers.last, updatedCipher)
10411041
XCTAssertEqual(clientCiphers.moveToOrganizationCipher, cipher)
10421042
XCTAssertEqual(clientCiphers.moveToOrganizationOrganizationId, "5")
10431043

10441044
XCTAssertEqual(cipherService.shareCipherWithServerCiphers.last, Cipher(cipherView: updatedCipher))
10451045
}
10461046

1047+
/// `shareCipher()` migrates any attachments without an attachment key.
1048+
func test_shareCipher_attachmentMigration() async throws {
1049+
let account = Account.fixtureAccountLogin()
1050+
stateService.activeAccount = account
1051+
1052+
// The original cipher.
1053+
let cipherViewOriginal = CipherView.fixture(
1054+
attachments: [
1055+
.fixture(fileName: "file.txt", id: "1", key: nil),
1056+
.fixture(fileName: "existing-attachment-key.txt", id: "2", key: "abc"),
1057+
],
1058+
id: "1"
1059+
)
1060+
1061+
// The cipher after saving the new attachment, encrypted with an attachment key.
1062+
let cipherAfterAttachmentSave = Cipher.fixture(
1063+
attachments: [
1064+
.fixture(id: "1", fileName: "file.txt", key: nil),
1065+
.fixture(id: "2", fileName: "existing-attachment-key.txt", key: "abc"),
1066+
.fixture(id: "3", fileName: "file.txt", key: "def"),
1067+
],
1068+
id: "1"
1069+
)
1070+
cipherService.saveAttachmentWithServerResult = .success(cipherAfterAttachmentSave)
1071+
1072+
// The cipher after deleting the old attachment without an attachment key.
1073+
let cipherAfterAttachmentDelete = Cipher.fixture(
1074+
attachments: [
1075+
.fixture(id: "2", fileName: "existing-attachment-key.txt", key: "abc"),
1076+
.fixture(id: "3", fileName: "file.txt", key: "def"),
1077+
],
1078+
id: "1"
1079+
)
1080+
cipherService.deleteAttachmentWithServerResult = .success(cipherAfterAttachmentDelete)
1081+
clientService.mockVault.clientCiphers.moveToOrganizationResult = .success(
1082+
CipherView(cipher: cipherAfterAttachmentDelete)
1083+
)
1084+
1085+
// Temporary download file (would normally be created by the network layer).
1086+
let downloadUrl = FileManager.default.temporaryDirectory.appendingPathComponent("file.txt")
1087+
try Data("📁".utf8).write(to: downloadUrl)
1088+
cipherService.downloadAttachmentResult = .success(downloadUrl)
1089+
1090+
// Decrypted download file (would normally be created by the SDK when decrypting the attachment).
1091+
let attachmentsUrl = try FileManager.default.attachmentsUrl(for: account.profile.userId)
1092+
try FileManager.default.createDirectory(at: attachmentsUrl, withIntermediateDirectories: true)
1093+
let decryptUrl = attachmentsUrl.appendingPathComponent("file.txt")
1094+
try Data("🗂️".utf8).write(to: decryptUrl)
1095+
1096+
try await subject.shareCipher(cipherViewOriginal, newOrganizationId: "5", newCollectionIds: ["6", "7"])
1097+
1098+
let updatedCipherView = CipherView(cipher: cipherAfterAttachmentDelete).update(collectionIds: ["6", "7"])
1099+
1100+
// Attachment migration: download attachment, save updated and delete old.
1101+
XCTAssertEqual(cipherService.downloadAttachmentId, "1")
1102+
XCTAssertEqual(cipherService.saveAttachmentWithServerCipher, Cipher(cipherView: cipherViewOriginal))
1103+
XCTAssertEqual(cipherService.deleteAttachmentWithServerAttachmentId, "1")
1104+
XCTAssertThrowsError(try Data(contentsOf: downloadUrl))
1105+
XCTAssertThrowsError(try Data(contentsOf: decryptUrl))
1106+
1107+
// Share cipher with updated attachments.
1108+
XCTAssertEqual(cipherService.shareCipherWithServerCiphers, [Cipher(cipherView: updatedCipherView)])
1109+
XCTAssertEqual(clientCiphers.encryptedCiphers.last, updatedCipherView)
1110+
XCTAssertEqual(clientCiphers.moveToOrganizationCipher, CipherView(cipher: cipherAfterAttachmentDelete))
1111+
XCTAssertEqual(clientCiphers.moveToOrganizationOrganizationId, "5")
1112+
}
1113+
10471114
/// `shouldShowUnassignedCiphersAlert` is true if the feature flag is on,
10481115
/// we should check for this user, the user has organizations, and the user has unassigned ciphers.
10491116
func test_shouldShowUnassignedCiphersAlert() async {

0 commit comments

Comments
 (0)