From ad3121f5359900c16c172f3a33af31a7681a4647 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Wed, 14 May 2025 10:30:01 -0400 Subject: [PATCH] [PM-12423] Migrate Cipher Decryption to Use SDK (#14206) * Created mappings for client domain object to SDK * Add abstract decrypt observable * Added todo for future consideration * Added implementation to cipher service * Added adapter and unit tests * Created cipher encryption abstraction and service * Register cipher encryption service * Added tests for the cipher encryption service * changed signature * Updated feature flag name * added new function to be used for decrypting ciphers * Added new encryptedKey field * added new function to be used for decrypting ciphers * Manually set fields * Added encrypted key in attachment view * Fixed test * Updated references to use decrypt with feature flag * Added dependency * updated package.json * lint fix * fixed tests * Fixed small mapping issues * Fixed test * Added function to decrypt fido2 key value * Added function to decrypt fido2 key value and updated test * updated to use sdk function without prociding the key * updated localdata sdk type change * decrypt attachment content using sdk * Fixed dependency issues * updated package.json * Refactored service to handle getting decrypted buffer using the legacy and sdk implementations * updated services and component to use refactored version * Updated decryptCiphersWithSdk to use decryptManyLegacy for batch decryption, ensuring the SDK is only called once per batch * Fixed merge conflicts * Fixed merge conflicts * Fixed merge conflicts * Fixed lint issues * Moved getDecryptedAttachmentBuffer to cipher service * Moved getDecryptedAttachmentBuffer to cipher service * ensure CipherView properties are null instead of undefined * Fixed test * ensure AttachmentView properties are null instead of undefined * Linked ticket in comment * removed unused orgKey --- .../background/notification.background.ts | 4 +- .../autofill/popup/fido2/fido2.component.ts | 8 +- .../browser/src/background/main.background.ts | 9 + .../assign-collections.component.ts | 7 +- .../open-attachments.component.spec.ts | 1 + .../open-attachments.component.ts | 4 +- .../vault-password-history-v2.component.ts | 4 +- .../view-v2/view-v2.component.spec.ts | 1 + .../vault-v2/view-v2/view-v2.component.ts | 4 +- .../admin-console/commands/share.command.ts | 8 +- apps/cli/src/commands/edit.command.ts | 15 +- apps/cli/src/commands/get.command.ts | 4 +- .../service-container/service-container.ts | 9 + apps/cli/src/vault/create.command.ts | 8 +- .../services/desktop-autofill.service.ts | 4 +- .../encrypted-message-handler.service.ts | 4 +- .../vault/app/vault/attachments.component.ts | 3 + .../src/vault/app/vault/view.component.ts | 3 +- .../vault-item-dialog.component.ts | 8 +- .../components/collections.component.ts | 4 +- .../angular/src/components/share.component.ts | 8 +- .../src/services/jslib-services.module.ts | 10 + .../vault/components/add-edit.component.ts | 4 +- .../vault/components/attachments.component.ts | 49 ++- .../components/password-history.component.ts | 4 +- .../src/vault/components/view.component.ts | 23 +- libs/common/spec/utils.ts | 14 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../fido2/fido2-authenticator.service.spec.ts | 8 + .../fido2/fido2-authenticator.service.ts | 4 +- .../abstractions/cipher-encryption.service.ts | 60 ++++ .../src/vault/abstractions/cipher.service.ts | 25 ++ .../models/api/cipher-permissions.api.ts | 17 + .../src/vault/models/data/cipher.data.ts | 2 +- .../vault/models/domain/attachment.spec.ts | 17 + .../src/vault/models/domain/attachment.ts | 18 + .../src/vault/models/domain/card.spec.ts | 17 + libs/common/src/vault/models/domain/card.ts | 18 + .../src/vault/models/domain/cipher.spec.ts | 167 ++++++++- libs/common/src/vault/models/domain/cipher.ts | 70 ++++ .../models/domain/fido2-credential.spec.ts | 39 ++ .../vault/models/domain/fido2-credential.ts | 25 ++ .../src/vault/models/domain/field.spec.ts | 24 +- libs/common/src/vault/models/domain/field.ts | 17 + .../src/vault/models/domain/identity.spec.ts | 28 ++ .../src/vault/models/domain/identity.ts | 30 ++ .../src/vault/models/domain/login-uri.spec.ts | 15 + .../src/vault/models/domain/login-uri.ts | 15 + .../src/vault/models/domain/login.spec.ts | 48 +++ libs/common/src/vault/models/domain/login.ts | 19 + .../src/vault/models/domain/password.spec.ts | 13 + .../src/vault/models/domain/password.ts | 14 + .../vault/models/domain/secure-note.spec.ts | 13 + .../src/vault/models/domain/secure-note.ts | 13 + .../src/vault/models/domain/ssh-key.spec.ts | 13 + .../common/src/vault/models/domain/ssh-key.ts | 15 + .../vault/models/view/attachment.view.spec.ts | 55 +++ .../src/vault/models/view/attachment.view.ts | 40 +++ .../common/src/vault/models/view/card.view.ts | 13 + .../src/vault/models/view/cipher.view.spec.ts | 132 ++++++- .../src/vault/models/view/cipher.view.ts | 68 +++- .../models/view/fido2-credential.view.ts | 27 ++ .../src/vault/models/view/field.view.ts | 19 + .../src/vault/models/view/identity.view.ts | 13 + .../vault/models/view/login-uri-view.spec.ts | 22 ++ .../src/vault/models/view/login-uri.view.ts | 17 + .../src/vault/models/view/login.view.spec.ts | 35 +- .../src/vault/models/view/login.view.ts | 25 ++ .../models/view/password-history.view.spec.ts | 23 ++ .../models/view/password-history.view.ts | 17 + .../src/vault/models/view/secure-note.view.ts | 13 + .../src/vault/models/view/ssh-key.view.ts | 17 + .../src/vault/services/cipher.service.spec.ts | 87 ++++- .../src/vault/services/cipher.service.ts | 89 ++++- .../default-cipher-encryption.service.spec.ts | 334 ++++++++++++++++++ .../default-cipher-encryption.service.ts | 190 ++++++++++ .../bitwarden/bitwarden-json-importer.ts | 4 +- .../individual-vault-export.service.spec.ts | 15 +- .../individual-vault-export.service.ts | 36 +- .../src/services/org-vault-export.service.ts | 9 +- .../cipher-attachments.component.spec.ts | 1 + .../cipher-attachments.component.ts | 8 +- .../services/default-cipher-form.service.ts | 14 +- .../download-attachment.component.spec.ts | 35 +- .../download-attachment.component.ts | 41 +-- 85 files changed, 2171 insertions(+), 218 deletions(-) create mode 100644 libs/common/src/vault/abstractions/cipher-encryption.service.ts create mode 100644 libs/common/src/vault/services/default-cipher-encryption.service.spec.ts create mode 100644 libs/common/src/vault/services/default-cipher-encryption.service.ts diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 52920ec67a8..a73141b7e4d 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -894,9 +894,7 @@ export default class NotificationBackground { private async getDecryptedCipherById(cipherId: string, userId: UserId) { const cipher = await this.cipherService.get(cipherId, userId); if (cipher != null && cipher.type === CipherType.Login) { - return await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, userId), - ); + return await this.cipherService.decrypt(cipher, userId); } return null; } diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts index 0471d460fd5..6b7d9120195 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -216,9 +216,7 @@ export class Fido2Component implements OnInit, OnDestroy { this.ciphers = await Promise.all( message.cipherIds.map(async (cipherId) => { const cipher = await this.cipherService.get(cipherId, activeUserId); - return cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + return this.cipherService.decrypt(cipher, activeUserId); }), ); @@ -237,9 +235,7 @@ export class Fido2Component implements OnInit, OnDestroy { this.ciphers = await Promise.all( message.existingCipherIds.map(async (cipherId) => { const cipher = await this.cipherService.get(cipherId, activeUserId); - return cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + return this.cipherService.decrypt(cipher, activeUserId); }), ); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 85a9cd27c57..a724f857cd1 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -183,6 +183,7 @@ import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-st import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -199,6 +200,7 @@ import { DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; +import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; @@ -408,6 +410,7 @@ export default class MainBackground { endUserNotificationService: EndUserNotificationService; inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; taskService: TaskService; + cipherEncryptionService: CipherEncryptionService; ipcContentScriptManagerService: IpcContentScriptManagerService; ipcService: IpcService; @@ -856,6 +859,11 @@ export default class MainBackground { this.bulkEncryptService = new FallbackBulkEncryptService(this.encryptService); + this.cipherEncryptionService = new DefaultCipherEncryptionService( + this.sdkService, + this.logService, + ); + this.cipherService = new CipherService( this.keyService, this.domainSettingsService, @@ -871,6 +879,7 @@ export default class MainBackground { this.stateProvider, this.accountService, this.logService, + this.cipherEncryptionService, ); this.folderService = new FolderService( this.keyService, diff --git a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts b/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts index 27f3b7e5e18..7052be5ea62 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts @@ -11,7 +11,6 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { OrgKey, UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { @@ -66,11 +65,7 @@ export class AssignCollections { route.queryParams.pipe( switchMap(async ({ cipherId }) => { const cipherDomain = await this.cipherService.get(cipherId, userId); - const key: UserKey | OrgKey = await this.cipherService.getKeyForCipherKeyDecryption( - cipherDomain, - userId, - ); - return cipherDomain.decrypt(key); + return await this.cipherService.decrypt(cipherDomain, userId); }), ), ), diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts index 66d9096cd5c..ec5c93feb9e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts @@ -81,6 +81,7 @@ describe("OpenAttachmentsComponent", () => { useValue: { get: getCipher, getKeyForCipherKeyDecryption: () => Promise.resolve(null), + decrypt: jest.fn().mockResolvedValue(cipherView), }, }, { diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts index 1bc7e22e6d5..9189ea51313 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts @@ -81,9 +81,7 @@ export class OpenAttachmentsComponent implements OnInit { this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId); - const cipher = await cipherDomain.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain, activeUserId), - ); + const cipher = await this.cipherService.decrypt(cipherDomain, activeUserId); if (!cipher.organizationId) { this.cipherIsAPartOfFreeOrg = false; diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts index 5d315775b10..d0eef20f044 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts @@ -69,8 +69,6 @@ export class PasswordHistoryV2Component implements OnInit { const activeUserId = activeAccount.id as UserId; const cipher = await this.cipherService.get(cipherId, activeUserId); - this.cipher = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + this.cipher = await this.cipherService.decrypt(cipher, activeUserId); } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts index 44874221a59..3222f39a162 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts @@ -82,6 +82,7 @@ describe("ViewV2Component", () => { getKeyForCipherKeyDecryption: jest.fn().mockResolvedValue({}), deleteWithServer: jest.fn().mockResolvedValue(undefined), softDeleteWithServer: jest.fn().mockResolvedValue(undefined), + decrypt: jest.fn().mockResolvedValue(mockCipher), }; beforeEach(async () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index a834314560b..0a71caf5aee 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -203,9 +203,7 @@ export class ViewV2Component { async getCipherData(id: string, userId: UserId) { const cipher = await this.cipherService.get(id, userId); - return await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, userId), - ); + return await this.cipherService.decrypt(cipher, userId); } async editCipher() { diff --git a/apps/cli/src/admin-console/commands/share.command.ts b/apps/cli/src/admin-console/commands/share.command.ts index 6d9e6c8b6c0..540bc2659c9 100644 --- a/apps/cli/src/admin-console/commands/share.command.ts +++ b/apps/cli/src/admin-console/commands/share.command.ts @@ -59,15 +59,11 @@ export class ShareCommand { return Response.badRequest("This item already belongs to an organization."); } - const cipherView = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + const cipherView = await this.cipherService.decrypt(cipher, activeUserId); try { await this.cipherService.shareWithServer(cipherView, organizationId, req, activeUserId); const updatedCipher = await this.cipherService.get(cipher.id, activeUserId); - const decCipher = await updatedCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId), - ); + const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index 2d4a854135d..4dcf805661d 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -90,9 +90,7 @@ export class EditCommand { return Response.notFound(); } - let cipherView = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + let cipherView = await this.cipherService.decrypt(cipher, activeUserId); if (cipherView.isDeleted) { return Response.badRequest("You may not edit a deleted item. Use the restore command first."); } @@ -100,9 +98,7 @@ export class EditCommand { const encCipher = await this.cipherService.encrypt(cipherView, activeUserId); try { const updatedCipher = await this.cipherService.updateWithServer(encCipher); - const decCipher = await updatedCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId), - ); + const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { @@ -132,12 +128,7 @@ export class EditCommand { cipher, activeUserId, ); - const decCipher = await updatedCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption( - updatedCipher, - await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)), - ), - ); + const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index 1bdbd051585..c3ba6044f8a 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -116,9 +116,7 @@ export class GetCommand extends DownloadCommand { if (Utils.isGuid(id)) { const cipher = await this.cipherService.get(id, activeUserId); if (cipher != null) { - decCipher = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + decCipher = await this.cipherService.decrypt(cipher, activeUserId); } } else if (id.trim() !== "") { let ciphers = await this.cipherService.getAllDecrypted(activeUserId); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index fe2f506f229..cdf6c4bbfda 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -139,12 +139,14 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; +import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; @@ -284,6 +286,7 @@ export class ServiceContainer { ssoUrlService: SsoUrlService; masterPasswordApiService: MasterPasswordApiServiceAbstraction; bulkEncryptService: FallbackBulkEncryptService; + cipherEncryptionService: CipherEncryptionService; constructor() { let p = null; @@ -679,6 +682,11 @@ export class ServiceContainer { this.accountService, ); + this.cipherEncryptionService = new DefaultCipherEncryptionService( + this.sdkService, + this.logService, + ); + this.cipherService = new CipherService( this.keyService, this.domainSettingsService, @@ -694,6 +702,7 @@ export class ServiceContainer { this.stateProvider, this.accountService, this.logService, + this.cipherEncryptionService, ); this.folderService = new FolderService( diff --git a/apps/cli/src/vault/create.command.ts b/apps/cli/src/vault/create.command.ts index 5b34d2cb507..b1536e23748 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -93,9 +93,7 @@ export class CreateCommand { const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId); try { const newCipher = await this.cipherService.createWithServer(cipher); - const decCipher = await newCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(newCipher, activeUserId), - ); + const decCipher = await this.cipherService.decrypt(newCipher, activeUserId); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { @@ -162,9 +160,7 @@ export class CreateCommand { new Uint8Array(fileBuf).buffer, activeUserId, ); - const decCipher = await updatedCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId), - ); + const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId); return Response.success(new CipherResponse(decCipher)); } catch (e) { return Response.error(e); diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index e88e16c2ffc..d6dddf3b23f 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -199,9 +199,7 @@ export class DesktopAutofillService implements OnDestroy { return; } - const decrypted = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + const decrypted = await this.cipherService.decrypt(cipher, activeUserId); const fido2Credential = decrypted.login.fido2Credentials?.[0]; if (!fido2Credential) { diff --git a/apps/desktop/src/services/encrypted-message-handler.service.ts b/apps/desktop/src/services/encrypted-message-handler.service.ts index 591ff6fa8cf..37a8114c1d1 100644 --- a/apps/desktop/src/services/encrypted-message-handler.service.ts +++ b/apps/desktop/src/services/encrypted-message-handler.service.ts @@ -207,9 +207,7 @@ export class EncryptedMessageHandlerService { return { status: "failure" }; } - const cipherView = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + const cipherView = await this.cipherService.decrypt(cipher, activeUserId); cipherView.name = credentialUpdatePayload.name; cipherView.login.password = credentialUpdatePayload.password; cipherView.login.username = credentialUpdatePayload.userName; diff --git a/apps/desktop/src/vault/app/vault/attachments.component.ts b/apps/desktop/src/vault/app/vault/attachments.component.ts index ea4f49b8431..a2cea5f2722 100644 --- a/apps/desktop/src/vault/app/vault/attachments.component.ts +++ b/apps/desktop/src/vault/app/vault/attachments.component.ts @@ -5,6 +5,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -33,6 +34,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, toastService: ToastService, + configService: ConfigService, ) { super( cipherService, @@ -49,6 +51,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { billingAccountProfileStateService, accountService, toastService, + configService, ); } } diff --git a/apps/desktop/src/vault/app/vault/view.component.ts b/apps/desktop/src/vault/app/vault/view.component.ts index e5f677cbca6..e74b07445da 100644 --- a/apps/desktop/src/vault/app/vault/view.component.ts +++ b/apps/desktop/src/vault/app/vault/view.component.ts @@ -72,7 +72,7 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro accountService: AccountService, toastService: ToastService, cipherAuthorizationService: CipherAuthorizationService, - private configService: ConfigService, + configService: ConfigService, ) { super( cipherService, @@ -100,6 +100,7 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro billingAccountProfileStateService, toastService, cipherAuthorizationService, + configService, ); } diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 10c35f861b9..aa457e97093 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -481,9 +481,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { activeUserId, ); - updatedCipherView = await updatedCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId), - ); + updatedCipherView = await this.cipherService.decrypt(updatedCipher, activeUserId); } this.cipherFormComponent.patchCipher((currentCipher) => { @@ -520,9 +518,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return; } const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - return await config.originalCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(config.originalCipher, activeUserId), - ); + return await this.cipherService.decrypt(config.originalCipher, activeUserId); } private updateTitle() { diff --git a/libs/angular/src/admin-console/components/collections.component.ts b/libs/angular/src/admin-console/components/collections.component.ts index 5f39966468f..8ae90705f92 100644 --- a/libs/angular/src/admin-console/components/collections.component.ts +++ b/libs/angular/src/admin-console/components/collections.component.ts @@ -50,9 +50,7 @@ export class CollectionsComponent implements OnInit { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); this.cipherDomain = await this.loadCipher(activeUserId); this.collectionIds = this.loadCipherCollections(); - this.cipher = await this.cipherDomain.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId), - ); + this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId); this.collections = await this.loadCollections(); this.collections.forEach((c) => ((c as any).checked = false)); diff --git a/libs/angular/src/components/share.component.ts b/libs/angular/src/components/share.component.ts index e785441b8e4..198cc7dc3a5 100644 --- a/libs/angular/src/components/share.component.ts +++ b/libs/angular/src/components/share.component.ts @@ -76,9 +76,7 @@ export class ShareComponent implements OnInit, OnDestroy { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId); - this.cipher = await cipherDomain.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain, activeUserId), - ); + this.cipher = await this.cipherService.decrypt(cipherDomain, activeUserId); } filterCollections() { @@ -105,9 +103,7 @@ export class ShareComponent implements OnInit, OnDestroy { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId); - const cipherView = await cipherDomain.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain, activeUserId), - ); + const cipherView = await this.cipherService.decrypt(cipherDomain, activeUserId); const orgs = await firstValueFrom(this.organizations$); const orgName = orgs.find((o) => o.id === this.organizationId)?.name ?? this.i18nService.t("organization"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 3ffca776034..920d35a1017 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -263,6 +263,7 @@ import { InternalSendService, SendService as SendServiceAbstraction, } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -281,6 +282,7 @@ import { DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; +import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; @@ -509,6 +511,7 @@ const safeProviders: SafeProvider[] = [ stateProvider: StateProvider, accountService: AccountServiceAbstraction, logService: LogService, + cipherEncryptionService: CipherEncryptionService, ) => new CipherService( keyService, @@ -525,6 +528,7 @@ const safeProviders: SafeProvider[] = [ stateProvider, accountService, logService, + cipherEncryptionService, ), deps: [ KeyService, @@ -541,6 +545,7 @@ const safeProviders: SafeProvider[] = [ StateProvider, AccountServiceAbstraction, LogService, + CipherEncryptionService, ], }), safeProvider({ @@ -1528,6 +1533,11 @@ const safeProviders: SafeProvider[] = [ useClass: MasterPasswordApiService, deps: [ApiServiceAbstraction, LogService], }), + safeProvider({ + provide: CipherEncryptionService, + useClass: DefaultCipherEncryptionService, + deps: [SdkService, LogService], + }), ]; @NgModule({ diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index b9defa8383d..b04adc1fdfb 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -269,9 +269,7 @@ export class AddEditComponent implements OnInit, OnDestroy { if (this.cipher == null) { if (this.editMode) { const cipher = await this.loadCipher(activeUserId); - this.cipher = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + this.cipher = await this.cipherService.decrypt(cipher, activeUserId); // Adjust Cipher Name if Cloning if (this.cloneMode) { diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index 9e9450c587e..e4b01d3aac1 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -9,13 +9,13 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; -import { UserId } from "@bitwarden/common/types/guid"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -56,6 +56,7 @@ export class AttachmentsComponent implements OnInit { protected billingAccountProfileStateService: BillingAccountProfileStateService, protected accountService: AccountService, protected toastService: ToastService, + protected configService: ConfigService, ) {} async ngOnInit() { @@ -88,9 +89,7 @@ export class AttachmentsComponent implements OnInit { const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.formPromise = this.saveCipherAttachment(files[0], activeUserId); this.cipherDomain = await this.formPromise; - this.cipher = await this.cipherDomain.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId), - ); + this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId); this.toastService.showToast({ variant: "success", title: null, @@ -130,9 +129,7 @@ export class AttachmentsComponent implements OnInit { const updatedCipher = await this.deletePromises[attachment.id]; const cipher = new Cipher(updatedCipher); - this.cipher = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + this.cipher = await this.cipherService.decrypt(cipher, activeUserId); this.toastService.showToast({ variant: "success", @@ -197,12 +194,14 @@ export class AttachmentsComponent implements OnInit { } try { - const encBuf = await EncArrayBuffer.fromResponse(response); - const key = - attachment.key != null - ? attachment.key - : await this.keyService.getOrgKey(this.cipher.organizationId); - const decBuf = await this.encryptService.decryptFileData(encBuf, key); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const decBuf = await this.cipherService.getDecryptedAttachmentBuffer( + this.cipherDomain.id as CipherId, + attachment, + response, + activeUserId, + ); + this.fileDownloadService.download({ fileName: attachment.fileName, blobData: decBuf, @@ -228,9 +227,7 @@ export class AttachmentsComponent implements OnInit { protected async init() { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); this.cipherDomain = await this.loadCipher(activeUserId); - this.cipher = await this.cipherDomain.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId), - ); + this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId); const canAccessPremium = await firstValueFrom( this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId), @@ -276,15 +273,17 @@ export class AttachmentsComponent implements OnInit { try { // 2. Resave - const encBuf = await EncArrayBuffer.fromResponse(response); - const key = - attachment.key != null - ? attachment.key - : await this.keyService.getOrgKey(this.cipher.organizationId); - const decBuf = await this.encryptService.decryptFileData(encBuf, key); const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), ); + + const decBuf = await this.cipherService.getDecryptedAttachmentBuffer( + this.cipherDomain.id as CipherId, + attachment, + response, + activeUserId, + ); + this.cipherDomain = await this.cipherService.saveAttachmentRawWithServer( this.cipherDomain, attachment.fileName, @@ -292,9 +291,7 @@ export class AttachmentsComponent implements OnInit { activeUserId, admin, ); - this.cipher = await this.cipherDomain.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, activeUserId), - ); + this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId); // 3. Delete old this.deletePromises[attachment.id] = this.deleteCipherAttachment( diff --git a/libs/angular/src/vault/components/password-history.component.ts b/libs/angular/src/vault/components/password-history.component.ts index 4df9f4bd24d..acb89b82191 100644 --- a/libs/angular/src/vault/components/password-history.component.ts +++ b/libs/angular/src/vault/components/password-history.component.ts @@ -42,9 +42,7 @@ export class PasswordHistoryComponent implements OnInit { protected async init() { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipher = await this.cipherService.get(this.cipherId, activeUserId); - const decCipher = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + const decCipher = await this.cipherService.decrypt(cipher, activeUserId); this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory; } } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 9d5a8fe9e62..8915cb6b671 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -34,13 +34,13 @@ import { EventType } from "@bitwarden/common/enums"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; -import { CollectionId, UserId } from "@bitwarden/common/types/guid"; +import { CipherId, CollectionId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; @@ -137,6 +137,7 @@ export class ViewComponent implements OnDestroy, OnInit { private billingAccountProfileStateService: BillingAccountProfileStateService, protected toastService: ToastService, private cipherAuthorizationService: CipherAuthorizationService, + protected configService: ConfigService, ) {} ngOnInit() { @@ -458,19 +459,19 @@ export class ViewComponent implements OnDestroy, OnInit { } try { - const encBuf = await EncArrayBuffer.fromResponse(response); - const key = - attachment.key != null - ? attachment.key - : await this.keyService.getOrgKey(this.cipher.organizationId); - const decBuf = await this.encryptService.decryptFileData(encBuf, key); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const decBuf = await this.cipherService.getDecryptedAttachmentBuffer( + this.cipher.id as CipherId, + attachment, + response, + activeUserId, + ); + this.fileDownloadService.download({ fileName: attachment.fileName, blobData: decBuf, }); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { + } catch { this.toastService.showToast({ variant: "error", title: null, diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index 51db65d0ce0..2b9b2567895 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -64,6 +64,20 @@ export function makeSymmetricCryptoKey( */ export const mockFromJson = (stub: any) => (stub + "_fromJSON") as any; +/** + * Use to mock a return value of a static fromSdk method. + */ +export const mockFromSdk = (stub: any) => { + if (typeof stub === "object") { + return { + ...stub, + __fromSdk: true, + }; + } + + return `${stub}_fromSdk`; +}; + /** * Tracks the emissions of the given observable. * diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ddc75eb0d66..d349703bddf 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -57,6 +57,7 @@ export enum FeatureFlag { PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge", PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form", SecurityTasks = "security-tasks", + PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", CipherKeyEncryption = "cipher-key-encryption", PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms", EndUserNotifications = "pm-10609-end-user-notifications", @@ -111,6 +112,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.CipherKeyEncryption]: FALSE, [FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE, [FeatureFlag.EndUserNotifications]: FALSE, + [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, /* Auth */ [FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE, diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index 3ea86a1f504..5c377e1a980 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -152,6 +152,7 @@ describe("FidoAuthenticatorService", () => { id === excludedCipher.id ? ({ decrypt: () => excludedCipher } as any) : undefined, ); cipherService.getAllDecrypted.mockResolvedValue([excludedCipher]); + cipherService.decrypt.mockResolvedValue(excludedCipher); }); /** @@ -220,6 +221,7 @@ describe("FidoAuthenticatorService", () => { id === existingCipher.id ? ({ decrypt: () => existingCipher } as any) : undefined, ); cipherService.getAllDecrypted.mockResolvedValue([existingCipher]); + cipherService.decrypt.mockResolvedValue(existingCipher); }); /** @@ -306,6 +308,11 @@ describe("FidoAuthenticatorService", () => { const encryptedCipher = { ...existingCipher, reprompt: CipherRepromptType.Password }; cipherService.get.mockResolvedValue(encryptedCipher as unknown as Cipher); + cipherService.decrypt.mockResolvedValue({ + ...existingCipher, + reprompt: CipherRepromptType.Password, + } as unknown as CipherView); + const result = async () => await authenticator.makeCredential(params, windowReference); await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); @@ -347,6 +354,7 @@ describe("FidoAuthenticatorService", () => { cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined, ); cipherService.getAllDecrypted.mockResolvedValue([await cipher]); + cipherService.decrypt.mockResolvedValue(cipher); cipherService.encrypt.mockImplementation(async (cipher) => { cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability return {} as any; diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index 76bd19b2876..a605e466338 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -151,9 +151,7 @@ export class Fido2AuthenticatorService ); const encrypted = await this.cipherService.get(cipherId, activeUserId); - cipher = await encrypted.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(encrypted, activeUserId), - ); + cipher = await this.cipherService.decrypt(encrypted, activeUserId); if ( !userVerified && diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts new file mode 100644 index 00000000000..6b2a8e8943e --- /dev/null +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -0,0 +1,60 @@ +import { CipherListView } from "@bitwarden/sdk-internal"; + +import { UserId } from "../../types/guid"; +import { Cipher } from "../models/domain/cipher"; +import { AttachmentView } from "../models/view/attachment.view"; +import { CipherView } from "../models/view/cipher.view"; + +/** + * Service responsible for encrypting and decrypting ciphers. + */ +export abstract class CipherEncryptionService { + /** + * Decrypts a cipher using the SDK for the given userId. + * + * @param cipher The encrypted cipher object + * @param userId The user ID whose key will be used for decryption + * + * @returns A promise that resolves to the decrypted cipher view + */ + abstract decrypt(cipher: Cipher, userId: UserId): Promise; + /** + * Decrypts many ciphers using the SDK for the given userId. + * + * For bulk decryption, prefer using `decryptMany`, which returns a more efficient + * `CipherListView` object. + * + * @param ciphers The encrypted cipher objects + * @param userId The user ID whose key will be used for decryption + * + * @deprecated Use `decryptMany` for bulk decryption instead. + * + * @returns A promise that resolves to an array of decrypted cipher views + */ + abstract decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise; + /** + * Decrypts many ciphers using the SDK for the given userId. + * + * @param ciphers The encrypted cipher objects + * @param userId The user ID whose key will be used for decryption + * + * @returns A promise that resolves to an array of decrypted cipher list views + */ + abstract decryptMany(ciphers: Cipher[], userId: UserId): Promise; + /** + * Decrypts an attachment's content from a response object. + * + * @param cipher The encrypted cipher object that owns the attachment + * @param attachment The attachment view object + * @param encryptedContent The encrypted content of the attachment + * @param userId The user ID whose key will be used for decryption + * + * @returns A promise that resolves to the decrypted content + */ + abstract decryptAttachmentContent( + cipher: Cipher, + attachment: AttachmentView, + encryptedContent: Uint8Array, + userId: UserId, + ): Promise; +} diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index b12488a5e03..a67dfcef8b9 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -14,6 +14,7 @@ import { LocalData } from "../models/data/local.data"; import { Cipher } from "../models/domain/cipher"; import { Field } from "../models/domain/field"; import { CipherWithIdRequest } from "../models/request/cipher-with-id.request"; +import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; @@ -215,4 +216,28 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract getNextCardCipher(userId: UserId): Promise; abstract getNextIdentityCipher(userId: UserId): Promise; + + /** + * Decrypts a cipher using either the SDK or the legacy method based on the feature flag. + * @param cipher The cipher to decrypt. + * @param userId The user ID to use for decryption. + * @returns A promise that resolves to the decrypted cipher view. + */ + abstract decrypt(cipher: Cipher, userId: UserId): Promise; + /** + * Decrypts an attachment's content from a response object. + * + * @param cipherId The ID of the cipher that owns the attachment + * @param attachment The attachment view object + * @param response The response object containing the encrypted content + * @param userId The user ID whose key will be used for decryption + * + * @returns A promise that resolves to the decrypted content + */ + abstract getDecryptedAttachmentBuffer( + cipherId: CipherId, + attachment: AttachmentView, + response: Response, + userId: UserId, + ): Promise; } diff --git a/libs/common/src/vault/models/api/cipher-permissions.api.ts b/libs/common/src/vault/models/api/cipher-permissions.api.ts index 4df7f891e26..b7341d39b1d 100644 --- a/libs/common/src/vault/models/api/cipher-permissions.api.ts +++ b/libs/common/src/vault/models/api/cipher-permissions.api.ts @@ -1,5 +1,7 @@ import { Jsonify } from "type-fest"; +import { CipherPermissions as SdkCipherPermissions } from "@bitwarden/sdk-internal"; + import { BaseResponse } from "../../../models/response/base.response"; export class CipherPermissionsApi extends BaseResponse { @@ -18,4 +20,19 @@ export class CipherPermissionsApi extends BaseResponse { static fromJSON(obj: Jsonify) { return Object.assign(new CipherPermissionsApi(), obj); } + + /** + * Converts the SDK CipherPermissionsApi to a CipherPermissionsApi. + */ + static fromSdkCipherPermissions(obj: SdkCipherPermissions): CipherPermissionsApi | undefined { + if (!obj) { + return undefined; + } + + const permissions = new CipherPermissionsApi(); + permissions.delete = obj.delete; + permissions.restore = obj.restore; + + return permissions; + } } diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index ee5e5b3e72b..1be70283fb3 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -39,7 +39,7 @@ export class CipherData { passwordHistory?: PasswordHistoryData[]; collectionIds?: string[]; creationDate: string; - deletedDate: string; + deletedDate: string | null; reprompt: CipherRepromptType; key: string; diff --git a/libs/common/src/vault/models/domain/attachment.spec.ts b/libs/common/src/vault/models/domain/attachment.spec.ts index 40d7ea7f05c..eab67320679 100644 --- a/libs/common/src/vault/models/domain/attachment.spec.ts +++ b/libs/common/src/vault/models/domain/attachment.spec.ts @@ -153,4 +153,21 @@ describe("Attachment", () => { expect(Attachment.fromJSON(null)).toBeNull(); }); }); + + describe("toSdkAttachment", () => { + it("should map to SDK Attachment", () => { + const attachment = new Attachment(data); + + const sdkAttachment = attachment.toSdkAttachment(); + + expect(sdkAttachment).toEqual({ + id: "id", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "fileName", + key: "key", + }); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index 15ce06e0881..4339f31a2e1 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { Attachment as SdkAttachment } from "@bitwarden/sdk-internal"; + import { Utils } from "../../../platform/misc/utils"; import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; @@ -113,4 +115,20 @@ export class Attachment extends Domain { fileName, }); } + + /** + * Maps to SDK Attachment + * + * @returns {SdkAttachment} - The SDK Attachment object + */ + toSdkAttachment(): SdkAttachment { + return { + id: this.id, + url: this.url, + size: this.size, + sizeName: this.sizeName, + fileName: this.fileName?.toJSON(), + key: this.key?.toJSON(), + }; + } } diff --git a/libs/common/src/vault/models/domain/card.spec.ts b/libs/common/src/vault/models/domain/card.spec.ts index a7011966d94..19546ddcb05 100644 --- a/libs/common/src/vault/models/domain/card.spec.ts +++ b/libs/common/src/vault/models/domain/card.spec.ts @@ -99,4 +99,21 @@ describe("Card", () => { expect(Card.fromJSON(null)).toBeNull(); }); }); + + describe("toSdkCard", () => { + it("should map to SDK Card", () => { + const card = new Card(data); + + const sdkCard = card.toSdkCard(); + + expect(sdkCard).toEqual({ + cardholderName: "encHolder", + brand: "encBrand", + number: "encNumber", + expMonth: "encMonth", + expYear: "encYear", + code: "encCode", + }); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/card.ts b/libs/common/src/vault/models/domain/card.ts index 3d73a8f527c..43068012ef6 100644 --- a/libs/common/src/vault/models/domain/card.ts +++ b/libs/common/src/vault/models/domain/card.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { Card as SdkCard } from "@bitwarden/sdk-internal"; + import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -85,4 +87,20 @@ export class Card extends Domain { code, }); } + + /** + * Maps Card to SDK format. + * + * @returns {SdkCard} The SDK card object. + */ + toSdkCard(): SdkCard { + return { + cardholderName: this.cardholderName?.toJSON(), + brand: this.brand?.toJSON(), + number: this.number?.toJSON(), + expMonth: this.expMonth?.toJSON(), + expYear: this.expYear?.toJSON(), + code: this.code?.toJSON(), + }; + } } diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index 0ef2233120a..a889f0b969c 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -3,6 +3,12 @@ import { Jsonify } from "type-fest"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { KeyService } from "@bitwarden/key-management"; +import { + CipherType as SdkCipherType, + UriMatchType, + CipherRepromptType as SdkCipherRepromptType, + LoginLinkedIdType, +} from "@bitwarden/sdk-internal"; import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; @@ -12,7 +18,7 @@ import { ContainerService } from "../../../platform/services/container.service"; import { InitializerKey } from "../../../platform/services/cryptography/initializer-key"; import { UserId } from "../../../types/guid"; import { CipherService } from "../../abstractions/cipher.service"; -import { FieldType, SecureNoteType } from "../../enums"; +import { FieldType, LoginLinkedId, SecureNoteType } from "../../enums"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherData } from "../../models/data/cipher.data"; @@ -770,6 +776,165 @@ describe("Cipher DTO", () => { expect(Cipher.fromJSON(null)).toBeNull(); }); }); + + describe("toSdkCipher", () => { + it("should map to SDK Cipher", () => { + const lastUsedDate = new Date("2025-04-15T12:00:00.000Z").getTime(); + const lastLaunched = new Date("2025-04-15T12:00:00.000Z").getTime(); + + const cipherData: CipherData = { + id: "id", + organizationId: "orgId", + folderId: "folderId", + edit: true, + permissions: new CipherPermissionsApi(), + viewPassword: true, + organizationUseTotp: true, + favorite: false, + revisionDate: "2022-01-31T12:00:00.000Z", + type: CipherType.Login, + name: "EncryptedString", + notes: "EncryptedString", + creationDate: "2022-01-01T12:00:00.000Z", + deletedDate: null, + reprompt: CipherRepromptType.None, + key: "EncryptedString", + login: { + uris: [ + { + uri: "EncryptedString", + uriChecksum: "EncryptedString", + match: UriMatchStrategy.Domain, + }, + ], + username: "EncryptedString", + password: "EncryptedString", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + totp: "EncryptedString", + autofillOnPageLoad: false, + }, + passwordHistory: [ + { password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }, + ], + attachments: [ + { + id: "a1", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + { + id: "a2", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + ], + fields: [ + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedId.Username, + }, + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedId.Password, + }, + ], + }; + + const cipher = new Cipher(cipherData, { lastUsedDate, lastLaunched }); + const sdkCipher = cipher.toSdkCipher(); + + expect(sdkCipher).toEqual({ + id: "id", + organizationId: "orgId", + folderId: "folderId", + collectionIds: [], + key: "EncryptedString", + name: "EncryptedString", + notes: "EncryptedString", + type: SdkCipherType.Login, + login: { + username: "EncryptedString", + password: "EncryptedString", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + uris: [ + { + uri: "EncryptedString", + uriChecksum: "EncryptedString", + match: UriMatchType.Domain, + }, + ], + totp: "EncryptedString", + autofillOnPageLoad: false, + fido2Credentials: undefined, + }, + identity: undefined, + card: undefined, + secureNote: undefined, + sshKey: undefined, + favorite: false, + reprompt: SdkCipherRepromptType.None, + organizationUseTotp: true, + edit: true, + permissions: new CipherPermissionsApi(), + viewPassword: true, + localData: { + lastUsedDate: "2025-04-15T12:00:00.000Z", + lastLaunched: "2025-04-15T12:00:00.000Z", + }, + attachments: [ + { + id: "a1", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + { + id: "a2", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + ], + fields: [ + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedIdType.Username, + }, + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedIdType.Password, + }, + ], + passwordHistory: [ + { + password: "EncryptedString", + lastUsedDate: "2022-01-31T12:00:00.000Z", + }, + ], + creationDate: "2022-01-01T12:00:00.000Z", + deletedDate: undefined, + revisionDate: "2022-01-31T12:00:00.000Z", + }); + }); + }); }); const mockUserId = "TestUserId" as UserId; diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 780690217a8..f647adf198e 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { Cipher as SdkCipher } from "@bitwarden/sdk-internal"; + import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; import { Utils } from "../../../platform/misc/utils"; import Domain from "../../../platform/models/domain/domain-base"; @@ -330,4 +332,72 @@ export class Cipher extends Domain implements Decryptable { return domain; } + + /** + * Maps Cipher to SDK format. + * + * @returns {SdkCipher} The SDK cipher object. + */ + toSdkCipher(): SdkCipher { + const sdkCipher: SdkCipher = { + id: this.id, + organizationId: this.organizationId, + folderId: this.folderId, + collectionIds: this.collectionIds || [], + key: this.key?.toJSON(), + name: this.name.toJSON(), + notes: this.notes?.toJSON(), + type: this.type, + favorite: this.favorite, + organizationUseTotp: this.organizationUseTotp, + edit: this.edit, + permissions: this.permissions, + viewPassword: this.viewPassword, + localData: this.localData + ? { + lastUsedDate: this.localData.lastUsedDate + ? new Date(this.localData.lastUsedDate).toISOString() + : undefined, + lastLaunched: this.localData.lastLaunched + ? new Date(this.localData.lastLaunched).toISOString() + : undefined, + } + : undefined, + attachments: this.attachments?.map((a) => a.toSdkAttachment()), + fields: this.fields?.map((f) => f.toSdkField()), + passwordHistory: this.passwordHistory?.map((ph) => ph.toSdkPasswordHistory()), + revisionDate: this.revisionDate?.toISOString(), + creationDate: this.creationDate?.toISOString(), + deletedDate: this.deletedDate?.toISOString(), + reprompt: this.reprompt, + // Initialize all cipher-type-specific properties as undefined + login: undefined, + identity: undefined, + card: undefined, + secureNote: undefined, + sshKey: undefined, + }; + + switch (this.type) { + case CipherType.Login: + sdkCipher.login = this.login.toSdkLogin(); + break; + case CipherType.SecureNote: + sdkCipher.secureNote = this.secureNote.toSdkSecureNote(); + break; + case CipherType.Card: + sdkCipher.card = this.card.toSdkCard(); + break; + case CipherType.Identity: + sdkCipher.identity = this.identity.toSdkIdentity(); + break; + case CipherType.SshKey: + sdkCipher.sshKey = this.sshKey.toSdkSshKey(); + break; + default: + break; + } + + return sdkCipher; + } } diff --git a/libs/common/src/vault/models/domain/fido2-credential.spec.ts b/libs/common/src/vault/models/domain/fido2-credential.spec.ts index e2cddcea3f3..bde29d0e99c 100644 --- a/libs/common/src/vault/models/domain/fido2-credential.spec.ts +++ b/libs/common/src/vault/models/domain/fido2-credential.spec.ts @@ -167,6 +167,45 @@ describe("Fido2Credential", () => { expect(Fido2Credential.fromJSON(null)).toBeNull(); }); }); + + describe("SDK Fido2Credential Mapping", () => { + it("should map to SDK Fido2Credential", () => { + const data: Fido2CredentialData = { + credentialId: "credentialId", + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: "keyValue", + rpId: "rpId", + userHandle: "userHandle", + userName: "userName", + counter: "2", + rpName: "rpName", + userDisplayName: "userDisplayName", + discoverable: "discoverable", + creationDate: mockDate.toISOString(), + }; + + const credential = new Fido2Credential(data); + const sdkCredential = credential.toSdkFido2Credential(); + + expect(sdkCredential).toEqual({ + credentialId: "credentialId", + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: "keyValue", + rpId: "rpId", + userHandle: "userHandle", + userName: "userName", + counter: "2", + rpName: "rpName", + userDisplayName: "userDisplayName", + discoverable: "discoverable", + creationDate: mockDate.toISOString(), + }); + }); + }); }); function createEncryptedEncString(s: string): EncString { diff --git a/libs/common/src/vault/models/domain/fido2-credential.ts b/libs/common/src/vault/models/domain/fido2-credential.ts index 8b0082892e4..7002a58150d 100644 --- a/libs/common/src/vault/models/domain/fido2-credential.ts +++ b/libs/common/src/vault/models/domain/fido2-credential.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { Fido2Credential as SdkFido2Credential } from "@bitwarden/sdk-internal"; + import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -148,4 +150,27 @@ export class Fido2Credential extends Domain { creationDate, }); } + + /** + * Maps Fido2Credential to SDK format. + * + * @returns {SdkFido2Credential} The SDK Fido2Credential object. + */ + toSdkFido2Credential(): SdkFido2Credential { + return { + credentialId: this.credentialId?.toJSON(), + keyType: this.keyType.toJSON(), + keyAlgorithm: this.keyAlgorithm.toJSON(), + keyCurve: this.keyCurve.toJSON(), + keyValue: this.keyValue.toJSON(), + rpId: this.rpId.toJSON(), + userHandle: this.userHandle.toJSON(), + userName: this.userName.toJSON(), + counter: this.counter.toJSON(), + rpName: this.rpName?.toJSON(), + userDisplayName: this.userDisplayName?.toJSON(), + discoverable: this.discoverable?.toJSON(), + creationDate: this.creationDate.toISOString(), + }; + } } diff --git a/libs/common/src/vault/models/domain/field.spec.ts b/libs/common/src/vault/models/domain/field.spec.ts index 7dc5556e6cf..c0f9713f7ab 100644 --- a/libs/common/src/vault/models/domain/field.spec.ts +++ b/libs/common/src/vault/models/domain/field.spec.ts @@ -1,6 +1,6 @@ import { mockEnc, mockFromJson } from "../../../../spec"; import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; -import { FieldType } from "../../enums"; +import { CardLinkedId, FieldType, IdentityLinkedId, LoginLinkedId } from "../../enums"; import { FieldData } from "../../models/data/field.data"; import { Field } from "../../models/domain/field"; @@ -82,4 +82,26 @@ describe("Field", () => { expect(Field.fromJSON(null)).toBeNull(); }); }); + + describe("SDK Field Mapping", () => { + it("should map to SDK Field", () => { + // Test Login LinkedId + const loginField = new Field(data); + loginField.type = FieldType.Linked; + loginField.linkedId = LoginLinkedId.Username; + expect(loginField.toSdkField().linkedId).toBe(100); + + // Test Card LinkedId + const cardField = new Field(data); + cardField.type = FieldType.Linked; + cardField.linkedId = CardLinkedId.Number; + expect(cardField.toSdkField().linkedId).toBe(305); + + // Test Identity LinkedId + const identityField = new Field(data); + identityField.type = FieldType.Linked; + identityField.linkedId = IdentityLinkedId.LicenseNumber; + expect(identityField.toSdkField().linkedId).toBe(415); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/field.ts b/libs/common/src/vault/models/domain/field.ts index c0f08a38bcc..223c9b39163 100644 --- a/libs/common/src/vault/models/domain/field.ts +++ b/libs/common/src/vault/models/domain/field.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { Field as SdkField, LinkedIdType as SdkLinkedIdType } from "@bitwarden/sdk-internal"; + import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -73,4 +75,19 @@ export class Field extends Domain { value, }); } + + /** + * Maps Field to SDK format. + * + * @returns {SdkField} The SDK field object. + */ + toSdkField(): SdkField { + return { + name: this.name?.toJSON(), + value: this.value?.toJSON(), + type: this.type, + // Safe type cast: client and SDK LinkedIdType enums have identical values + linkedId: this.linkedId as unknown as SdkLinkedIdType, + }; + } } diff --git a/libs/common/src/vault/models/domain/identity.spec.ts b/libs/common/src/vault/models/domain/identity.spec.ts index 3a95138998b..cf296a6ff08 100644 --- a/libs/common/src/vault/models/domain/identity.spec.ts +++ b/libs/common/src/vault/models/domain/identity.spec.ts @@ -184,4 +184,32 @@ describe("Identity", () => { expect(Identity.fromJSON(null)).toBeNull(); }); }); + + describe("toSdkIdentity", () => { + it("returns the correct SDK Identity object", () => { + const identity = new Identity(data); + const sdkIdentity = identity.toSdkIdentity(); + + expect(sdkIdentity).toEqual({ + title: "enctitle", + firstName: "encfirstName", + middleName: "encmiddleName", + lastName: "enclastName", + address1: "encaddress1", + address2: "encaddress2", + address3: "encaddress3", + city: "enccity", + state: "encstate", + postalCode: "encpostalCode", + country: "enccountry", + company: "enccompany", + email: "encemail", + phone: "encphone", + ssn: "encssn", + username: "encusername", + passportNumber: "encpassportNumber", + licenseNumber: "enclicenseNumber", + }); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/identity.ts b/libs/common/src/vault/models/domain/identity.ts index 5d8c20ef2b3..c7756733a66 100644 --- a/libs/common/src/vault/models/domain/identity.ts +++ b/libs/common/src/vault/models/domain/identity.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { Identity as SdkIdentity } from "@bitwarden/sdk-internal"; + import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -165,4 +167,32 @@ export class Identity extends Domain { licenseNumber, }); } + + /** + * Maps Identity to SDK format. + * + * @returns {SdkIdentity} The SDK identity object. + */ + toSdkIdentity(): SdkIdentity { + return { + title: this.title?.toJSON(), + firstName: this.firstName?.toJSON(), + middleName: this.middleName?.toJSON(), + lastName: this.lastName?.toJSON(), + address1: this.address1?.toJSON(), + address2: this.address2?.toJSON(), + address3: this.address3?.toJSON(), + city: this.city?.toJSON(), + state: this.state?.toJSON(), + postalCode: this.postalCode?.toJSON(), + country: this.country?.toJSON(), + company: this.company?.toJSON(), + email: this.email?.toJSON(), + phone: this.phone?.toJSON(), + ssn: this.ssn?.toJSON(), + username: this.username?.toJSON(), + passportNumber: this.passportNumber?.toJSON(), + licenseNumber: this.licenseNumber?.toJSON(), + }; + } } diff --git a/libs/common/src/vault/models/domain/login-uri.spec.ts b/libs/common/src/vault/models/domain/login-uri.spec.ts index 6346f38f0de..a0e6b6d7dc9 100644 --- a/libs/common/src/vault/models/domain/login-uri.spec.ts +++ b/libs/common/src/vault/models/domain/login-uri.spec.ts @@ -1,6 +1,8 @@ import { MockProxy, mock } from "jest-mock-extended"; import { Jsonify } from "type-fest"; +import { UriMatchType } from "@bitwarden/sdk-internal"; + import { mockEnc, mockFromJson } from "../../../../spec"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { UriMatchStrategy } from "../../../models/domain/domain-service"; @@ -118,4 +120,17 @@ describe("LoginUri", () => { expect(LoginUri.fromJSON(null)).toBeNull(); }); }); + + describe("SDK Login Uri Mapping", () => { + it("should map to SDK login uri", () => { + const loginUri = new LoginUri(data); + const sdkLoginUri = loginUri.toSdkLoginUri(); + + expect(sdkLoginUri).toEqual({ + uri: "encUri", + uriChecksum: "encUriChecksum", + match: UriMatchType.Domain, + }); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/login-uri.ts b/libs/common/src/vault/models/domain/login-uri.ts index 883f8c9a616..b3e6fad70dd 100644 --- a/libs/common/src/vault/models/domain/login-uri.ts +++ b/libs/common/src/vault/models/domain/login-uri.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { LoginUri as SdkLoginUri } from "@bitwarden/sdk-internal"; + import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { Utils } from "../../../platform/misc/utils"; import Domain from "../../../platform/models/domain/domain-base"; @@ -87,4 +89,17 @@ export class LoginUri extends Domain { uriChecksum, }); } + + /** + * Maps LoginUri to SDK format. + * + * @returns {SdkLoginUri} The SDK login uri object. + */ + toSdkLoginUri(): SdkLoginUri { + return { + uri: this.uri.toJSON(), + uriChecksum: this.uriChecksum.toJSON(), + match: this.match, + }; + } } diff --git a/libs/common/src/vault/models/domain/login.spec.ts b/libs/common/src/vault/models/domain/login.spec.ts index 4f9e4546220..84d12e8131f 100644 --- a/libs/common/src/vault/models/domain/login.spec.ts +++ b/libs/common/src/vault/models/domain/login.spec.ts @@ -202,6 +202,54 @@ describe("Login DTO", () => { expect(Login.fromJSON(null)).toBeNull(); }); }); + + describe("toSdkLogin", () => { + it("should map to SDK login", () => { + const data: LoginData = { + uris: [{ uri: "uri", uriChecksum: "checksum", match: UriMatchStrategy.Domain }], + username: "username", + password: "password", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + totp: "123", + autofillOnPageLoad: false, + fido2Credentials: [initializeFido2Credential(new Fido2CredentialData())], + }; + const login = new Login(data); + const sdkLogin = login.toSdkLogin(); + + expect(sdkLogin).toEqual({ + username: "username", + password: "password", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + uris: [ + { + match: 0, + uri: "uri", + uriChecksum: "checksum", + }, + ], + totp: "123", + autofillOnPageLoad: false, + fido2Credentials: [ + { + credentialId: "credentialId", + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: "keyValue", + rpId: "rpId", + userHandle: "userHandle", + userName: "userName", + counter: "counter", + rpName: "rpName", + userDisplayName: "userDisplayName", + discoverable: "discoverable", + creationDate: "2023-01-01T12:00:00.000Z", + }, + ], + }); + }); + }); }); type Fido2CredentialLike = Fido2CredentialData | Fido2CredentialView | Fido2CredentialApi; diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index b29b42bf3de..1893212bdaa 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { Login as SdkLogin } from "@bitwarden/sdk-internal"; + import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -144,4 +146,21 @@ export class Login extends Domain { fido2Credentials, }); } + + /** + * Maps Login to SDK format. + * + * @returns {SdkLogin} The SDK login object. + */ + toSdkLogin(): SdkLogin { + return { + uris: this.uris?.map((u) => u.toSdkLoginUri()), + username: this.username?.toJSON(), + password: this.password?.toJSON(), + passwordRevisionDate: this.passwordRevisionDate?.toISOString(), + totp: this.totp?.toJSON(), + autofillOnPageLoad: this.autofillOnPageLoad, + fido2Credentials: this.fido2Credentials?.map((f) => f.toSdkFido2Credential()), + }; + } } diff --git a/libs/common/src/vault/models/domain/password.spec.ts b/libs/common/src/vault/models/domain/password.spec.ts index 614b9639e52..24163cccf36 100644 --- a/libs/common/src/vault/models/domain/password.spec.ts +++ b/libs/common/src/vault/models/domain/password.spec.ts @@ -70,4 +70,17 @@ describe("Password", () => { expect(Password.fromJSON(null)).toBeNull(); }); }); + + describe("toSdkPasswordHistory", () => { + it("returns the correct SDK PasswordHistory object", () => { + const password = new Password(data); + + const sdkPasswordHistory = password.toSdkPasswordHistory(); + + expect(sdkPasswordHistory).toEqual({ + password: "encPassword", + lastUsedDate: new Date("2022-01-31T12:00:00.000Z").toISOString(), + }); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/password.ts b/libs/common/src/vault/models/domain/password.ts index 8573c224416..b69a61a95a9 100644 --- a/libs/common/src/vault/models/domain/password.ts +++ b/libs/common/src/vault/models/domain/password.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { PasswordHistory } from "@bitwarden/sdk-internal"; + import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -57,4 +59,16 @@ export class Password extends Domain { lastUsedDate, }); } + + /** + * Maps Password to SDK format. + * + * @returns {PasswordHistory} The SDK password history object. + */ + toSdkPasswordHistory(): PasswordHistory { + return { + password: this.password.toJSON(), + lastUsedDate: this.lastUsedDate.toISOString(), + }; + } } diff --git a/libs/common/src/vault/models/domain/secure-note.spec.ts b/libs/common/src/vault/models/domain/secure-note.spec.ts index 719cf59f136..ff71e53238d 100644 --- a/libs/common/src/vault/models/domain/secure-note.spec.ts +++ b/libs/common/src/vault/models/domain/secure-note.spec.ts @@ -50,4 +50,17 @@ describe("SecureNote", () => { expect(SecureNote.fromJSON(null)).toBeNull(); }); }); + + describe("toSdkSecureNote", () => { + it("returns the correct SDK SecureNote object", () => { + const secureNote = new SecureNote(); + secureNote.type = SecureNoteType.Generic; + + const sdkSecureNote = secureNote.toSdkSecureNote(); + + expect(sdkSecureNote).toEqual({ + type: 0, + }); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/secure-note.ts b/libs/common/src/vault/models/domain/secure-note.ts index 693ae38d9fb..ac7977b0e46 100644 --- a/libs/common/src/vault/models/domain/secure-note.ts +++ b/libs/common/src/vault/models/domain/secure-note.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { SecureNote as SdkSecureNote } from "@bitwarden/sdk-internal"; + import Domain from "../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { SecureNoteType } from "../../enums"; @@ -41,4 +43,15 @@ export class SecureNote extends Domain { return Object.assign(new SecureNote(), obj); } + + /** + * Maps Secure note to SDK format. + * + * @returns {SdkSecureNote} The SDK secure note object. + */ + toSdkSecureNote(): SdkSecureNote { + return { + type: this.type, + }; + } } diff --git a/libs/common/src/vault/models/domain/ssh-key.spec.ts b/libs/common/src/vault/models/domain/ssh-key.spec.ts index f56d738fde8..6576d1a41e9 100644 --- a/libs/common/src/vault/models/domain/ssh-key.spec.ts +++ b/libs/common/src/vault/models/domain/ssh-key.spec.ts @@ -64,4 +64,17 @@ describe("Sshkey", () => { expect(SshKey.fromJSON(null)).toBeNull(); }); }); + + describe("toSdkSshKey", () => { + it("returns the correct SDK SshKey object", () => { + const sshKey = new SshKey(data); + const sdkSshKey = sshKey.toSdkSshKey(); + + expect(sdkSshKey).toEqual({ + privateKey: "privateKey", + publicKey: "publicKey", + fingerprint: "keyFingerprint", + }); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/ssh-key.ts b/libs/common/src/vault/models/domain/ssh-key.ts index f32a1a913ca..96a1c9e58de 100644 --- a/libs/common/src/vault/models/domain/ssh-key.ts +++ b/libs/common/src/vault/models/domain/ssh-key.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { SshKey as SdkSshKey } from "@bitwarden/sdk-internal"; + import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -70,4 +72,17 @@ export class SshKey extends Domain { keyFingerprint, }); } + + /** + * Maps SSH key to SDK format. + * + * @returns {SdkSshKey} The SDK SSH key object. + */ + toSdkSshKey(): SdkSshKey { + return { + privateKey: this.privateKey.toJSON(), + publicKey: this.publicKey.toJSON(), + fingerprint: this.keyFingerprint.toJSON(), + }; + } } diff --git a/libs/common/src/vault/models/view/attachment.view.spec.ts b/libs/common/src/vault/models/view/attachment.view.spec.ts index 7cb291f2714..8ae836e1265 100644 --- a/libs/common/src/vault/models/view/attachment.view.spec.ts +++ b/libs/common/src/vault/models/view/attachment.view.spec.ts @@ -1,4 +1,7 @@ +import { AttachmentView as SdkAttachmentView } from "@bitwarden/sdk-internal"; + import { mockFromJson } from "../../../../spec"; +import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { AttachmentView } from "./attachment.view"; @@ -15,4 +18,56 @@ describe("AttachmentView", () => { expect(actual.key).toEqual("encKeyB64_fromJSON"); }); + + describe("fromSdkAttachmentView", () => { + it("should return undefined when the input is null", () => { + const result = AttachmentView.fromSdkAttachmentView(null as unknown as any); + expect(result).toBeUndefined(); + }); + + it("should return an AttachmentView from an SdkAttachmentView", () => { + const sdkAttachmentView = { + id: "id", + url: "url", + size: "size", + sizeName: "sizeName", + fileName: "fileName", + key: "encKeyB64_fromString", + } as SdkAttachmentView; + + const result = AttachmentView.fromSdkAttachmentView(sdkAttachmentView); + + expect(result).toMatchObject({ + id: "id", + url: "url", + size: "size", + sizeName: "sizeName", + fileName: "fileName", + key: null, + encryptedKey: new EncString(sdkAttachmentView.key as string), + }); + }); + }); + + describe("toSdkAttachmentView", () => { + it("should convert AttachmentView to SdkAttachmentView", () => { + const attachmentView = new AttachmentView(); + attachmentView.id = "id"; + attachmentView.url = "url"; + attachmentView.size = "size"; + attachmentView.sizeName = "sizeName"; + attachmentView.fileName = "fileName"; + attachmentView.encryptedKey = new EncString("encKeyB64"); + + const result = attachmentView.toSdkAttachmentView(); + expect(result).toEqual({ + id: "id", + url: "url", + size: "size", + sizeName: "sizeName", + fileName: "fileName", + key: "encKeyB64", + }); + }); + }); }); diff --git a/libs/common/src/vault/models/view/attachment.view.ts b/libs/common/src/vault/models/view/attachment.view.ts index 09839ed2fef..2ef4280d97a 100644 --- a/libs/common/src/vault/models/view/attachment.view.ts +++ b/libs/common/src/vault/models/view/attachment.view.ts @@ -2,7 +2,10 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { AttachmentView as SdkAttachmentView } from "@bitwarden/sdk-internal"; + import { View } from "../../../models/view/view"; +import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { Attachment } from "../domain/attachment"; @@ -13,6 +16,10 @@ export class AttachmentView implements View { sizeName: string = null; fileName: string = null; key: SymmetricCryptoKey = null; + /** + * The SDK returns an encrypted key for the attachment. + */ + encryptedKey: EncString | undefined; constructor(a?: Attachment) { if (!a) { @@ -40,4 +47,37 @@ export class AttachmentView implements View { const key = obj.key == null ? null : SymmetricCryptoKey.fromJSON(obj.key); return Object.assign(new AttachmentView(), obj, { key: key }); } + + /** + * Converts the AttachmentView to a SDK AttachmentView. + */ + toSdkAttachmentView(): SdkAttachmentView { + return { + id: this.id, + url: this.url, + size: this.size, + sizeName: this.sizeName, + fileName: this.fileName, + key: this.encryptedKey?.toJSON(), + }; + } + + /** + * Converts the SDK AttachmentView to a AttachmentView. + */ + static fromSdkAttachmentView(obj: SdkAttachmentView): AttachmentView | undefined { + if (!obj) { + return undefined; + } + + const view = new AttachmentView(); + view.id = obj.id ?? null; + view.url = obj.url ?? null; + view.size = obj.size ?? null; + view.sizeName = obj.sizeName ?? null; + view.fileName = obj.fileName ?? null; + view.encryptedKey = new EncString(obj.key); + + return view; + } } diff --git a/libs/common/src/vault/models/view/card.view.ts b/libs/common/src/vault/models/view/card.view.ts index 9eeb4dabf4d..2adfbb39e89 100644 --- a/libs/common/src/vault/models/view/card.view.ts +++ b/libs/common/src/vault/models/view/card.view.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { CardView as SdkCardView } from "@bitwarden/sdk-internal"; + import { normalizeExpiryYearFormat } from "../../../autofill/utils"; import { CardLinkedId as LinkedId } from "../../enums"; import { linkedFieldOption } from "../../linked-field-option.decorator"; @@ -146,4 +148,15 @@ export class CardView extends ItemView { return null; } + + /** + * Converts an SDK CardView to a CardView. + */ + static fromSdkCardView(obj: SdkCardView): CardView | undefined { + if (obj == null) { + return undefined; + } + + return Object.assign(new CardView(), obj); + } } diff --git a/libs/common/src/vault/models/view/cipher.view.spec.ts b/libs/common/src/vault/models/view/cipher.view.spec.ts index 3ab2706d356..b9d3e42aa62 100644 --- a/libs/common/src/vault/models/view/cipher.view.spec.ts +++ b/libs/common/src/vault/models/view/cipher.view.spec.ts @@ -1,4 +1,16 @@ -import { mockFromJson } from "../../../../spec"; +import { + CipherView as SdkCipherView, + CipherType as SdkCipherType, + CipherRepromptType as SdkCipherRepromptType, + AttachmentView as SdkAttachmentView, + LoginUriView as SdkLoginUriView, + LoginView as SdkLoginView, + FieldView as SdkFieldView, + FieldType as SdkFieldType, +} from "@bitwarden/sdk-internal"; + +import { mockFromJson, mockFromSdk } from "../../../../spec"; +import { CipherRepromptType } from "../../enums"; import { CipherType } from "../../enums/cipher-type"; import { AttachmentView } from "./attachment.view"; @@ -9,6 +21,7 @@ import { IdentityView } from "./identity.view"; import { LoginView } from "./login.view"; import { PasswordHistoryView } from "./password-history.view"; import { SecureNoteView } from "./secure-note.view"; +import { SshKeyView } from "./ssh-key.view"; jest.mock("../../models/view/login.view"); jest.mock("../../models/view/attachment.view"); @@ -73,4 +86,121 @@ describe("CipherView", () => { expect(actual).toMatchObject(expected); }); }); + + describe("fromSdkCipherView", () => { + let sdkCipherView: SdkCipherView; + + beforeEach(() => { + jest.spyOn(CardView, "fromSdkCardView").mockImplementation(mockFromSdk); + jest.spyOn(IdentityView, "fromSdkIdentityView").mockImplementation(mockFromSdk); + jest.spyOn(LoginView, "fromSdkLoginView").mockImplementation(mockFromSdk); + jest.spyOn(SecureNoteView, "fromSdkSecureNoteView").mockImplementation(mockFromSdk); + jest.spyOn(SshKeyView, "fromSdkSshKeyView").mockImplementation(mockFromSdk); + jest.spyOn(AttachmentView, "fromSdkAttachmentView").mockImplementation(mockFromSdk); + jest.spyOn(FieldView, "fromSdkFieldView").mockImplementation(mockFromSdk); + + sdkCipherView = { + id: "id", + organizationId: "orgId", + folderId: "folderId", + collectionIds: ["collectionId"], + key: undefined, + name: "name", + notes: undefined, + type: SdkCipherType.Login, + favorite: true, + edit: true, + reprompt: SdkCipherRepromptType.None, + organizationUseTotp: false, + viewPassword: true, + localData: undefined, + permissions: undefined, + attachments: [{ id: "attachmentId", url: "attachmentUrl" } as SdkAttachmentView], + login: { + username: "username", + password: "password", + uris: [{ uri: "bitwarden.com" } as SdkLoginUriView], + totp: "totp", + autofillOnPageLoad: true, + } as SdkLoginView, + identity: undefined, + card: undefined, + secureNote: undefined, + sshKey: undefined, + fields: [ + { + name: "fieldName", + value: "fieldValue", + type: SdkFieldType.Linked, + linkedId: 100, + } as SdkFieldView, + ], + passwordHistory: undefined, + creationDate: "2022-01-01T12:00:00.000Z", + revisionDate: "2022-01-02T12:00:00.000Z", + deletedDate: undefined, + }; + }); + + it("returns undefined when input is null", () => { + expect(CipherView.fromSdkCipherView(null as unknown as SdkCipherView)).toBeUndefined(); + }); + + it("maps properties correctly", () => { + const result = CipherView.fromSdkCipherView(sdkCipherView); + + expect(result).toMatchObject({ + id: "id", + organizationId: "orgId", + folderId: "folderId", + collectionIds: ["collectionId"], + name: "name", + notes: null, + type: CipherType.Login, + favorite: true, + edit: true, + reprompt: CipherRepromptType.None, + organizationUseTotp: false, + viewPassword: true, + localData: undefined, + permissions: undefined, + attachments: [ + { + id: "attachmentId", + url: "attachmentUrl", + __fromSdk: true, + }, + ], + login: { + username: "username", + password: "password", + uris: [ + { + uri: "bitwarden.com", + }, + ], + totp: "totp", + autofillOnPageLoad: true, + __fromSdk: true, + }, + identity: new IdentityView(), + card: new CardView(), + secureNote: new SecureNoteView(), + sshKey: new SshKeyView(), + fields: [ + { + name: "fieldName", + value: "fieldValue", + type: SdkFieldType.Linked, + linkedId: 100, + __fromSdk: true, + }, + ], + passwordHistory: null, + creationDate: new Date("2022-01-01T12:00:00.000Z"), + revisionDate: new Date("2022-01-02T12:00:00.000Z"), + deletedDate: null, + }); + }); + }); }); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 7ddba9e2ed5..1f73903a5bc 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; + import { View } from "../../../models/view/view"; import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; import { InitializerKey } from "../../../platform/services/cryptography/initializer-key"; @@ -110,7 +112,7 @@ export class CipherView implements View, InitializerMetadata { get hasOldAttachments(): boolean { if (this.hasAttachments) { for (let i = 0; i < this.attachments.length; i++) { - if (this.attachments[i].key == null) { + if (this.attachments[i].key == null && this.attachments[i].encryptedKey == null) { return true; } } @@ -222,4 +224,68 @@ export class CipherView implements View, InitializerMetadata { return view; } + + /** + * Creates a CipherView from the SDK CipherView. + */ + static fromSdkCipherView(obj: SdkCipherView): CipherView | undefined { + if (obj == null) { + return undefined; + } + + const cipherView = new CipherView(); + cipherView.id = obj.id ?? null; + cipherView.organizationId = obj.organizationId ?? null; + cipherView.folderId = obj.folderId ?? null; + cipherView.name = obj.name; + cipherView.notes = obj.notes ?? null; + cipherView.type = obj.type; + cipherView.favorite = obj.favorite; + cipherView.organizationUseTotp = obj.organizationUseTotp; + cipherView.permissions = CipherPermissionsApi.fromSdkCipherPermissions(obj.permissions); + cipherView.edit = obj.edit; + cipherView.viewPassword = obj.viewPassword; + cipherView.localData = obj.localData + ? { + lastUsedDate: obj.localData.lastUsedDate + ? new Date(obj.localData.lastUsedDate).getTime() + : undefined, + lastLaunched: obj.localData.lastLaunched + ? new Date(obj.localData.lastLaunched).getTime() + : undefined, + } + : undefined; + cipherView.attachments = + obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)) ?? null; + cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)) ?? null; + cipherView.passwordHistory = + obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)) ?? null; + cipherView.collectionIds = obj.collectionIds ?? null; + cipherView.revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); + cipherView.creationDate = obj.creationDate == null ? null : new Date(obj.creationDate); + cipherView.deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate); + cipherView.reprompt = obj.reprompt ?? CipherRepromptType.None; + + switch (obj.type) { + case CipherType.Card: + cipherView.card = CardView.fromSdkCardView(obj.card); + break; + case CipherType.Identity: + cipherView.identity = IdentityView.fromSdkIdentityView(obj.identity); + break; + case CipherType.Login: + cipherView.login = LoginView.fromSdkLoginView(obj.login); + break; + case CipherType.SecureNote: + cipherView.secureNote = SecureNoteView.fromSdkSecureNoteView(obj.secureNote); + break; + case CipherType.SshKey: + cipherView.sshKey = SshKeyView.fromSdkSshKeyView(obj.sshKey); + break; + default: + break; + } + + return cipherView; + } } diff --git a/libs/common/src/vault/models/view/fido2-credential.view.ts b/libs/common/src/vault/models/view/fido2-credential.view.ts index b364d63b8ea..bf1d324d22d 100644 --- a/libs/common/src/vault/models/view/fido2-credential.view.ts +++ b/libs/common/src/vault/models/view/fido2-credential.view.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { Fido2CredentialView as SdkFido2CredentialView } from "@bitwarden/sdk-internal"; + import { ItemView } from "./item.view"; export class Fido2CredentialView extends ItemView { @@ -29,4 +31,29 @@ export class Fido2CredentialView extends ItemView { creationDate, }); } + + /** + * Converts the SDK Fido2CredentialView to a Fido2CredentialView. + */ + static fromSdkFido2CredentialView(obj: SdkFido2CredentialView): Fido2CredentialView | undefined { + if (!obj) { + return undefined; + } + + const view = new Fido2CredentialView(); + view.credentialId = obj.credentialId; + view.keyType = obj.keyType as "public-key"; + view.keyAlgorithm = obj.keyAlgorithm as "ECDSA"; + view.keyCurve = obj.keyCurve as "P-256"; + view.rpId = obj.rpId; + view.userHandle = obj.userHandle; + view.userName = obj.userName; + view.counter = parseInt(obj.counter); + view.rpName = obj.rpName; + view.userDisplayName = obj.userDisplayName; + view.discoverable = obj.discoverable?.toLowerCase() === "true" ? true : false; + view.creationDate = obj.creationDate ? new Date(obj.creationDate) : null; + + return view; + } } diff --git a/libs/common/src/vault/models/view/field.view.ts b/libs/common/src/vault/models/view/field.view.ts index ef8c5113fd0..770150f8a63 100644 --- a/libs/common/src/vault/models/view/field.view.ts +++ b/libs/common/src/vault/models/view/field.view.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { FieldView as SdkFieldView } from "@bitwarden/sdk-internal"; + import { View } from "../../../models/view/view"; import { FieldType, LinkedIdType } from "../../enums"; import { Field } from "../domain/field"; @@ -31,4 +33,21 @@ export class FieldView implements View { static fromJSON(obj: Partial>): FieldView { return Object.assign(new FieldView(), obj); } + + /** + * Converts the SDK FieldView to a FieldView. + */ + static fromSdkFieldView(obj: SdkFieldView): FieldView | undefined { + if (!obj) { + return undefined; + } + + const view = new FieldView(); + view.name = obj.name; + view.value = obj.value; + view.type = obj.type; + view.linkedId = obj.linkedId as unknown as LinkedIdType; + + return view; + } } diff --git a/libs/common/src/vault/models/view/identity.view.ts b/libs/common/src/vault/models/view/identity.view.ts index 247e5cfec86..a75d11efd95 100644 --- a/libs/common/src/vault/models/view/identity.view.ts +++ b/libs/common/src/vault/models/view/identity.view.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { IdentityView as SdkIdentityView } from "@bitwarden/sdk-internal"; + import { Utils } from "../../../platform/misc/utils"; import { IdentityLinkedId as LinkedId } from "../../enums"; import { linkedFieldOption } from "../../linked-field-option.decorator"; @@ -158,4 +160,15 @@ export class IdentityView extends ItemView { static fromJSON(obj: Partial>): IdentityView { return Object.assign(new IdentityView(), obj); } + + /** + * Converts the SDK IdentityView to an IdentityView. + */ + static fromSdkIdentityView(obj: SdkIdentityView): IdentityView | undefined { + if (obj == null) { + return undefined; + } + + return Object.assign(new IdentityView(), obj); + } } diff --git a/libs/common/src/vault/models/view/login-uri-view.spec.ts b/libs/common/src/vault/models/view/login-uri-view.spec.ts index efc75096295..155d3d59f7c 100644 --- a/libs/common/src/vault/models/view/login-uri-view.spec.ts +++ b/libs/common/src/vault/models/view/login-uri-view.spec.ts @@ -1,3 +1,5 @@ +import { LoginUriView as SdkLoginUriView, UriMatchType } from "@bitwarden/sdk-internal"; + import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { Utils } from "../../../platform/misc/utils"; @@ -184,6 +186,26 @@ describe("LoginUriView", () => { }); }); }); + + describe("fromSdkLoginUriView", () => { + it("should return undefined when the input is null", () => { + const result = LoginUriView.fromSdkLoginUriView(null as unknown as SdkLoginUriView); + expect(result).toBeUndefined(); + }); + + it("should create a LoginUriView from a SdkLoginUriView", () => { + const sdkLoginUriView = { + uri: "https://example.com", + match: UriMatchType.Host, + } as SdkLoginUriView; + + const loginUriView = LoginUriView.fromSdkLoginUriView(sdkLoginUriView); + + expect(loginUriView).toBeInstanceOf(LoginUriView); + expect(loginUriView!.uri).toBe(sdkLoginUriView.uri); + expect(loginUriView!.match).toBe(sdkLoginUriView.match); + }); + }); }); function uriFactory(match: UriMatchStrategySetting, uri: string) { diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 315adb87c75..43d47aa4a3c 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { LoginUriView as SdkLoginUriView } from "@bitwarden/sdk-internal"; + import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { View } from "../../../models/view/view"; import { SafeUrls } from "../../../platform/misc/safe-urls"; @@ -112,6 +114,21 @@ export class LoginUriView implements View { return Object.assign(new LoginUriView(), obj); } + /** + * Converts a LoginUriView object from the SDK to a LoginUriView object. + */ + static fromSdkLoginUriView(obj: SdkLoginUriView): LoginUriView | undefined { + if (obj == null) { + return undefined; + } + + const view = new LoginUriView(); + view.uri = obj.uri; + view.match = obj.match; + + return view; + } + matchesUri( targetUri: string, equivalentDomains: Set, diff --git a/libs/common/src/vault/models/view/login.view.spec.ts b/libs/common/src/vault/models/view/login.view.spec.ts index 728a62deb9d..57e82faf7f1 100644 --- a/libs/common/src/vault/models/view/login.view.spec.ts +++ b/libs/common/src/vault/models/view/login.view.spec.ts @@ -1,4 +1,6 @@ -import { mockFromJson } from "../../../../spec"; +import { LoginView as SdkLoginView } from "@bitwarden/sdk-internal"; + +import { mockFromJson, mockFromSdk } from "../../../../spec"; import { LoginUriView } from "./login-uri.view"; import { LoginView } from "./login.view"; @@ -25,4 +27,35 @@ describe("LoginView", () => { uris: ["uri1_fromJSON", "uri2_fromJSON", "uri3_fromJSON"], }); }); + + describe("fromSdkLoginView", () => { + it("should return undefined when the input is null", () => { + const result = LoginView.fromSdkLoginView(null as unknown as SdkLoginView); + expect(result).toBeUndefined(); + }); + + it("should return a LoginView from an SdkLoginView", () => { + jest.spyOn(LoginUriView, "fromSdkLoginUriView").mockImplementation(mockFromSdk); + + const sdkLoginView = { + username: "username", + password: "password", + passwordRevisionDate: "2025-01-01T01:06:40.441Z", + uris: [{ uri: "bitwarden.com" } as any], + totp: "totp", + autofillOnPageLoad: true, + } as SdkLoginView; + + const result = LoginView.fromSdkLoginView(sdkLoginView); + + expect(result).toMatchObject({ + username: "username", + password: "password", + passwordRevisionDate: new Date("2025-01-01T01:06:40.441Z"), + uris: [expect.objectContaining({ uri: "bitwarden.com", __fromSdk: true })], + totp: "totp", + autofillOnPageLoad: true, + }); + }); + }); }); diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index 228f3a60c34..41568f643d5 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { LoginView as SdkLoginView } from "@bitwarden/sdk-internal"; + import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { Utils } from "../../../platform/misc/utils"; import { DeepJsonify } from "../../../types/deep-jsonify"; @@ -100,4 +102,27 @@ export class LoginView extends ItemView { fido2Credentials, }); } + + /** + * Converts the SDK LoginView to a LoginView. + * + * Note: FIDO2 credentials remain encrypted at this stage. + * Unlike other fields that are decrypted as part of the LoginView, the SDK maintains + * the FIDO2 credentials in encrypted form. We can decrypt them later using a separate + * call to client.vault().ciphers().decrypt_fido2_credentials(). + */ + static fromSdkLoginView(obj: SdkLoginView): LoginView | undefined { + if (obj == null) { + return undefined; + } + + const passwordRevisionDate = + obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); + const uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)); + + return Object.assign(new LoginView(), obj, { + passwordRevisionDate, + uris, + }); + } } diff --git a/libs/common/src/vault/models/view/password-history.view.spec.ts b/libs/common/src/vault/models/view/password-history.view.spec.ts index 7349e44454d..81894ec7493 100644 --- a/libs/common/src/vault/models/view/password-history.view.spec.ts +++ b/libs/common/src/vault/models/view/password-history.view.spec.ts @@ -1,3 +1,5 @@ +import { PasswordHistoryView as SdkPasswordHistoryView } from "@bitwarden/sdk-internal"; + import { PasswordHistoryView } from "./password-history.view"; describe("PasswordHistoryView", () => { @@ -10,4 +12,25 @@ describe("PasswordHistoryView", () => { expect(actual.lastUsedDate).toEqual(lastUsedDate); }); + + describe("fromSdkPasswordHistoryView", () => { + it("should return undefined when the input is null", () => { + const result = PasswordHistoryView.fromSdkPasswordHistoryView(null as unknown as any); + expect(result).toBeUndefined(); + }); + + it("should return a PasswordHistoryView from an SdkPasswordHistoryView", () => { + const sdkPasswordHistoryView = { + password: "password", + lastUsedDate: "2023-10-01T00:00:00Z", + } as SdkPasswordHistoryView; + + const result = PasswordHistoryView.fromSdkPasswordHistoryView(sdkPasswordHistoryView); + + expect(result).toMatchObject({ + password: "password", + lastUsedDate: new Date("2023-10-01T00:00:00Z"), + }); + }); + }); }); diff --git a/libs/common/src/vault/models/view/password-history.view.ts b/libs/common/src/vault/models/view/password-history.view.ts index 3ab360d5e09..31f05f4cc71 100644 --- a/libs/common/src/vault/models/view/password-history.view.ts +++ b/libs/common/src/vault/models/view/password-history.view.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { PasswordHistoryView as SdkPasswordHistoryView } from "@bitwarden/sdk-internal"; + import { View } from "../../../models/view/view"; import { Password } from "../domain/password"; @@ -24,4 +26,19 @@ export class PasswordHistoryView implements View { lastUsedDate: lastUsedDate, }); } + + /** + * Converts the SDK PasswordHistoryView to a PasswordHistoryView. + */ + static fromSdkPasswordHistoryView(obj: SdkPasswordHistoryView): PasswordHistoryView | undefined { + if (!obj) { + return undefined; + } + + const view = new PasswordHistoryView(); + view.password = obj.password; + view.lastUsedDate = obj.lastUsedDate == null ? null : new Date(obj.lastUsedDate); + + return view; + } } diff --git a/libs/common/src/vault/models/view/secure-note.view.ts b/libs/common/src/vault/models/view/secure-note.view.ts index c7dd4f8932d..075e4dfc520 100644 --- a/libs/common/src/vault/models/view/secure-note.view.ts +++ b/libs/common/src/vault/models/view/secure-note.view.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { SecureNoteView as SdkSecureNoteView } from "@bitwarden/sdk-internal"; + import { SecureNoteType } from "../../enums"; import { SecureNote } from "../domain/secure-note"; @@ -26,4 +28,15 @@ export class SecureNoteView extends ItemView { static fromJSON(obj: Partial>): SecureNoteView { return Object.assign(new SecureNoteView(), obj); } + + /** + * Converts the SDK SecureNoteView to a SecureNoteView. + */ + static fromSdkSecureNoteView(obj: SdkSecureNoteView): SecureNoteView | undefined { + if (!obj) { + return undefined; + } + + return Object.assign(new SecureNoteView(), obj); + } } diff --git a/libs/common/src/vault/models/view/ssh-key.view.ts b/libs/common/src/vault/models/view/ssh-key.view.ts index 8f1a9c5a65a..a3d091e4c07 100644 --- a/libs/common/src/vault/models/view/ssh-key.view.ts +++ b/libs/common/src/vault/models/view/ssh-key.view.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { SshKeyView as SdkSshKeyView } from "@bitwarden/sdk-internal"; + import { SshKey } from "../domain/ssh-key"; import { ItemView } from "./item.view"; @@ -44,4 +46,19 @@ export class SshKeyView extends ItemView { static fromJSON(obj: Partial>): SshKeyView { return Object.assign(new SshKeyView(), obj); } + + /** + * Converts the SDK SshKeyView to a SshKeyView. + */ + static fromSdkSshKeyView(obj: SdkSshKeyView): SshKeyView | undefined { + if (!obj) { + return undefined; + } + + const keyFingerprint = obj.fingerprint; + + return Object.assign(new SshKeyView(), obj, { + keyFingerprint, + }); + } } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index a8b37e8adc6..b15bc4a9112 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -6,7 +6,7 @@ import { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeStateProvider } from "../../../spec/fake-state-provider"; -import { makeStaticByteArray } from "../../../spec/utils"; +import { makeStaticByteArray, makeSymmetricCryptoKey } from "../../../spec/utils"; import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; import { AutofillSettingsService } from "../../autofill/services/autofill-settings.service"; @@ -24,6 +24,7 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt import { ContainerService } from "../../platform/services/container.service"; import { CipherId, UserId } from "../../types/guid"; import { CipherKey, OrgKey, UserKey } from "../../types/key"; +import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { FieldType } from "../enums"; import { CipherRepromptType } from "../enums/cipher-reprompt-type"; @@ -34,6 +35,7 @@ import { Cipher } from "../models/domain/cipher"; import { CipherCreateRequest } from "../models/request/cipher-create.request"; import { CipherPartialRequest } from "../models/request/cipher-partial.request"; import { CipherRequest } from "../models/request/cipher.request"; +import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; import { LoginUriView } from "../models/view/login-uri.view"; @@ -124,6 +126,7 @@ describe("Cipher Service", () => { accountService = mockAccountServiceWith(mockUserId); const logService = mock(); const stateProvider = new FakeStateProvider(accountService); + const cipherEncryptionService = mock(); const userId = "TestUserId" as UserId; @@ -151,6 +154,7 @@ describe("Cipher Service", () => { stateProvider, accountService, logService, + cipherEncryptionService, ); cipherObj = new Cipher(cipherData); @@ -478,4 +482,85 @@ describe("Cipher Service", () => { ).rejects.toThrow("Cannot rotate ciphers when decryption failures are present"); }); }); + + describe("decrypt", () => { + it("should call decrypt method of CipherEncryptionService when feature flag is true", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(cipherObj)); + + const result = await cipherService.decrypt(cipherObj, userId); + + expect(result).toEqual(new CipherView(cipherObj)); + expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(cipherObj, userId); + }); + + it("should call legacy decrypt when feature flag is false", async () => { + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + configService.getFeatureFlag.mockResolvedValue(false); + cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey); + encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); + jest.spyOn(cipherObj, "decrypt").mockResolvedValue(new CipherView(cipherObj)); + + const result = await cipherService.decrypt(cipherObj, userId); + + expect(result).toEqual(new CipherView(cipherObj)); + expect(cipherObj.decrypt).toHaveBeenCalledWith(mockUserKey); + }); + }); + + describe("getDecryptedAttachmentBuffer", () => { + const mockEncryptedContent = new Uint8Array([1, 2, 3]); + const mockDecryptedContent = new Uint8Array([4, 5, 6]); + + it("should use SDK when feature flag is enabled", async () => { + const cipher = new Cipher(cipherData); + const attachment = new AttachmentView(cipher.attachments![0]); + configService.getFeatureFlag.mockResolvedValue(true); + + jest.spyOn(cipherService, "ciphers$").mockReturnValue(of({ [cipher.id]: cipherData })); + cipherEncryptionService.decryptAttachmentContent.mockResolvedValue(mockDecryptedContent); + const mockResponse = { + arrayBuffer: jest.fn().mockResolvedValue(mockEncryptedContent.buffer), + } as unknown as Response; + + const result = await cipherService.getDecryptedAttachmentBuffer( + cipher.id as CipherId, + attachment, + mockResponse, + userId, + ); + + expect(result).toEqual(mockDecryptedContent); + expect(cipherEncryptionService.decryptAttachmentContent).toHaveBeenCalledWith( + cipher, + attachment, + mockEncryptedContent, + userId, + ); + }); + + it("should use legacy decryption when feature flag is enabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + const cipher = new Cipher(cipherData); + const attachment = new AttachmentView(cipher.attachments![0]); + attachment.key = makeSymmetricCryptoKey(64); + + const mockResponse = { + arrayBuffer: jest.fn().mockResolvedValue(mockEncryptedContent.buffer), + } as unknown as Response; + const mockEncBuf = {} as EncArrayBuffer; + EncArrayBuffer.fromResponse = jest.fn().mockResolvedValue(mockEncBuf); + encryptService.decryptFileData.mockResolvedValue(mockDecryptedContent); + + const result = await cipherService.getDecryptedAttachmentBuffer( + cipher.id as CipherId, + attachment, + mockResponse, + userId, + ); + + expect(result).toEqual(mockDecryptedContent); + expect(encryptService.decryptFileData).toHaveBeenCalledWith(mockEncBuf, attachment.key); + }); + }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 169568d44e9..6bea56baa5e 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -29,7 +29,8 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt import { StateProvider } from "../../platform/state"; import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid"; import { OrgKey, UserKey } from "../../types/key"; -import { perUserCache$ } from "../../vault/utils/observable-utilities"; +import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities"; +import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { FieldType } from "../enums"; @@ -103,6 +104,7 @@ export class CipherService implements CipherServiceAbstraction { private stateProvider: StateProvider, private accountService: AccountService, private logService: LogService, + private cipherEncryptionService: CipherEncryptionService, ) {} localData$(userId: UserId): Observable> { @@ -424,13 +426,21 @@ export class CipherService implements CipherServiceAbstraction { ciphers: Cipher[], userId: UserId, ): Promise<[CipherView[], CipherView[]] | null> { - const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId, true)); + if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) { + const decryptStartTime = new Date().getTime(); + const decrypted = await this.decryptCiphersWithSdk(ciphers, userId); + this.logService.info( + `[CipherService] Decrypting ${decrypted.length} ciphers took ${new Date().getTime() - decryptStartTime}ms`, + ); + // With SDK, failed ciphers are not returned + return [decrypted, []]; + } + const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId, true)); if (keys == null || (keys.userKey == null && Object.keys(keys.orgKeys).length === 0)) { // return early if there are no keys to decrypt with return null; } - // Group ciphers by orgId or under 'null' for the user's ciphers const grouped = ciphers.reduce( (agg, c) => { @@ -440,7 +450,6 @@ export class CipherService implements CipherServiceAbstraction { }, {} as Record, ); - const decryptStartTime = new Date().getTime(); const allCipherViews = ( await Promise.all( @@ -464,7 +473,6 @@ export class CipherService implements CipherServiceAbstraction { this.logService.info( `[CipherService] Decrypting ${allCipherViews.length} ciphers took ${new Date().getTime() - decryptStartTime}ms`, ); - // Split ciphers into two arrays, one for successfully decrypted ciphers and one for ciphers that failed to decrypt return allCipherViews.reduce( (acc, c) => { @@ -479,6 +487,21 @@ export class CipherService implements CipherServiceAbstraction { ); } + /** + * Decrypts a cipher using either the SDK or the legacy method based on the feature flag. + * @param cipher The cipher to decrypt. + * @param userId The user ID to use for decryption. + * @returns A promise that resolves to the decrypted cipher view. + */ + async decrypt(cipher: Cipher, userId: UserId): Promise { + if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) { + return await this.cipherEncryptionService.decrypt(cipher, userId); + } else { + const encKey = await this.getKeyForCipherKeyDecryption(cipher, userId); + return await cipher.decrypt(encKey); + } + } + private async reindexCiphers(userId: UserId) { const reindexRequired = this.searchService != null && @@ -895,7 +918,7 @@ export class CipherService implements CipherServiceAbstraction { //then we rollback to using the user key as the main key of encryption of the item //in order to keep item and it's attachments with the same encryption level if (cipher.key != null && !cipherKeyEncryptionEnabled) { - const model = await cipher.decrypt(await this.getKeyForCipherKeyDecryption(cipher, userId)); + const model = await this.decrypt(cipher, userId); cipher = await this.encrypt(model, userId); await this.updateWithServer(cipher); } @@ -1381,6 +1404,43 @@ export class CipherService implements CipherServiceAbstraction { return encryptedCiphers; } + async getDecryptedAttachmentBuffer( + cipherId: CipherId, + attachment: AttachmentView, + response: Response, + userId: UserId, + ): Promise { + const useSdkDecryption = await this.configService.getFeatureFlag( + FeatureFlag.PM19941MigrateCipherDomainToSdk, + ); + + const cipherDomain = await firstValueFrom( + this.ciphers$(userId).pipe(map((ciphersData) => new Cipher(ciphersData[cipherId]))), + ); + + if (useSdkDecryption) { + const encryptedContent = await response.arrayBuffer(); + return this.cipherEncryptionService.decryptAttachmentContent( + cipherDomain, + attachment, + new Uint8Array(encryptedContent), + userId, + ); + } + + const encBuf = await EncArrayBuffer.fromResponse(response); + const key = + attachment.key != null + ? attachment.key + : await firstValueFrom( + this.keyService.orgKeys$(userId).pipe( + filterOutNullish(), + map((orgKeys) => orgKeys[cipherDomain.organizationId as OrganizationId] as OrgKey), + ), + ); + return await this.encryptService.decryptFileData(encBuf, key); + } + /** * @returns a SingleUserState */ @@ -1430,9 +1490,7 @@ export class CipherService implements CipherServiceAbstraction { originalCipher: Cipher, userId: UserId, ): Promise { - const existingCipher = await originalCipher.decrypt( - await this.getKeyForCipherKeyDecryption(originalCipher, userId), - ); + const existingCipher = await this.decrypt(originalCipher, userId); model.passwordHistory = existingCipher.passwordHistory || []; if (model.type === CipherType.Login && existingCipher.type === CipherType.Login) { if ( @@ -1852,4 +1910,17 @@ export class CipherService implements CipherServiceAbstraction { ); return featureEnabled && meetsServerVersion; } + + /** + * Decrypts the provided ciphers using the SDK. + * @param ciphers The ciphers to decrypt. + * @param userId The user ID to use for decryption. + * @returns The decrypted ciphers. + * @private + */ + private async decryptCiphersWithSdk(ciphers: Cipher[], userId: UserId): Promise { + const decryptedViews = await this.cipherEncryptionService.decryptManyLegacy(ciphers, userId); + + return decryptedViews.sort(this.getLocaleSortingFunction()); + } } diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts new file mode 100644 index 00000000000..c0b3d3be85f --- /dev/null +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -0,0 +1,334 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { + Fido2Credential, + Cipher as SdkCipher, + CipherType as SdkCipherType, + CipherView as SdkCipherView, + CipherListView, + Attachment as SdkAttachment, +} from "@bitwarden/sdk-internal"; + +import { mockEnc } from "../../../spec"; +import { UriMatchStrategy } from "../../models/domain/domain-service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; +import { UserId } from "../../types/guid"; +import { CipherRepromptType, CipherType } from "../enums"; +import { CipherPermissionsApi } from "../models/api/cipher-permissions.api"; +import { CipherData } from "../models/data/cipher.data"; +import { Cipher } from "../models/domain/cipher"; +import { AttachmentView } from "../models/view/attachment.view"; +import { CipherView } from "../models/view/cipher.view"; +import { Fido2CredentialView } from "../models/view/fido2-credential.view"; + +import { DefaultCipherEncryptionService } from "./default-cipher-encryption.service"; + +const cipherData: CipherData = { + id: "id", + organizationId: "orgId", + folderId: "folderId", + edit: true, + viewPassword: true, + organizationUseTotp: true, + favorite: false, + revisionDate: "2022-01-31T12:00:00.000Z", + type: CipherType.Login, + name: "EncryptedString", + notes: "EncryptedString", + creationDate: "2022-01-01T12:00:00.000Z", + deletedDate: null, + permissions: new CipherPermissionsApi(), + key: "EncKey", + reprompt: CipherRepromptType.None, + login: { + uris: [ + { uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchStrategy.Domain }, + ], + username: "EncryptedString", + password: "EncryptedString", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + totp: "EncryptedString", + autofillOnPageLoad: false, + }, + passwordHistory: [{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }], + attachments: [ + { + id: "a1", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + { + id: "a2", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + ], +}; + +describe("DefaultCipherEncryptionService", () => { + let cipherEncryptionService: DefaultCipherEncryptionService; + const sdkService = mock(); + const logService = mock(); + let sdkCipherView: SdkCipherView; + + const mockSdkClient = { + vault: jest.fn().mockReturnValue({ + ciphers: jest.fn().mockReturnValue({ + decrypt: jest.fn(), + decrypt_list: jest.fn(), + decrypt_fido2_credentials: jest.fn(), + }), + attachments: jest.fn().mockReturnValue({ + decrypt_buffer: jest.fn(), + }), + }), + }; + const mockRef = { + value: mockSdkClient, + [Symbol.dispose]: jest.fn(), + }; + const mockSdk = { + take: jest.fn().mockReturnValue(mockRef), + }; + + const userId = "user-id" as UserId; + + let cipherObj: Cipher; + + beforeEach(() => { + sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any; + cipherEncryptionService = new DefaultCipherEncryptionService(sdkService, logService); + cipherObj = new Cipher(cipherData); + + jest.spyOn(cipherObj, "toSdkCipher").mockImplementation(() => { + return { id: cipherData.id } as SdkCipher; + }); + + sdkCipherView = { + id: "test-id", + type: SdkCipherType.Login, + name: "test-name", + login: { + username: "test-username", + password: "test-password", + }, + } as SdkCipherView; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("decrypt", () => { + it("should decrypt a cipher successfully", async () => { + const expectedCipherView: CipherView = { + id: "test-id", + type: CipherType.Login, + name: "test-name", + login: { + username: "test-username", + password: "test-password", + }, + } as CipherView; + + mockSdkClient.vault().ciphers().decrypt.mockReturnValue(sdkCipherView); + jest.spyOn(CipherView, "fromSdkCipherView").mockReturnValue(expectedCipherView); + + const result = await cipherEncryptionService.decrypt(cipherObj, userId); + + expect(result).toEqual(expectedCipherView); + expect(cipherObj.toSdkCipher).toHaveBeenCalledTimes(1); + expect(mockSdkClient.vault().ciphers().decrypt).toHaveBeenCalledWith({ id: cipherData.id }); + expect(CipherView.fromSdkCipherView).toHaveBeenCalledWith(sdkCipherView); + expect(mockSdkClient.vault().ciphers().decrypt_fido2_credentials).not.toHaveBeenCalled(); + }); + + it("should decrypt FIDO2 credentials if present", async () => { + const fido2Credentials = [ + { + credentialId: mockEnc("credentialId"), + keyType: mockEnc("keyType"), + keyAlgorithm: mockEnc("keyAlgorithm"), + keyCurve: mockEnc("keyCurve"), + keyValue: mockEnc("keyValue"), + rpId: mockEnc("rpId"), + userHandle: mockEnc("userHandle"), + userName: mockEnc("userName"), + counter: mockEnc("2"), + rpName: mockEnc("rpName"), + userDisplayName: mockEnc("userDisplayName"), + discoverable: mockEnc("true"), + creationDate: new Date("2023-01-01T12:00:00.000Z"), + }, + ] as unknown as Fido2Credential[]; + + sdkCipherView.login!.fido2Credentials = fido2Credentials; + + const expectedCipherView: CipherView = { + id: "test-id", + type: CipherType.Login, + name: "test-name", + login: { + username: "test-username", + password: "test-password", + fido2Credentials: [], + }, + } as unknown as CipherView; + + const fido2CredentialView: Fido2CredentialView = { + credentialId: "credentialId", + keyType: "keyType", + keyAlgorithm: "keyAlgorithm", + keyCurve: "keyCurve", + keyValue: "decrypted-key-value", + rpId: "rpId", + userHandle: "userHandle", + userName: "userName", + counter: 2, + rpName: "rpName", + userDisplayName: "userDisplayName", + discoverable: true, + creationDate: new Date("2023-01-01T12:00:00.000Z"), + } as unknown as Fido2CredentialView; + + mockSdkClient.vault().ciphers().decrypt.mockReturnValue(sdkCipherView); + mockSdkClient.vault().ciphers().decrypt_fido2_credentials.mockReturnValue(fido2Credentials); + mockSdkClient.vault().ciphers().decrypt_fido2_private_key = jest + .fn() + .mockReturnValue("decrypted-key-value"); + + jest.spyOn(CipherView, "fromSdkCipherView").mockReturnValue(expectedCipherView); + jest + .spyOn(Fido2CredentialView, "fromSdkFido2CredentialView") + .mockReturnValueOnce(fido2CredentialView); + + const result = await cipherEncryptionService.decrypt(cipherObj, userId); + + expect(result).toBe(expectedCipherView); + expect(result.login?.fido2Credentials).toEqual([fido2CredentialView]); + expect(mockSdkClient.vault().ciphers().decrypt_fido2_credentials).toHaveBeenCalledWith( + sdkCipherView, + ); + expect(mockSdkClient.vault().ciphers().decrypt_fido2_private_key).toHaveBeenCalledWith( + sdkCipherView, + ); + expect(Fido2CredentialView.fromSdkFido2CredentialView).toHaveBeenCalledTimes(1); + }); + }); + + describe("decryptManyLegacy", () => { + it("should decrypt multiple ciphers successfully", async () => { + const ciphers = [new Cipher(cipherData), new Cipher(cipherData)]; + + const expectedViews = [ + { + id: "test-id-1", + name: "test-name-1", + } as CipherView, + { + id: "test-id-2", + name: "test-name-2", + } as CipherView, + ]; + + mockSdkClient + .vault() + .ciphers() + .decrypt.mockReturnValueOnce({ id: "test-id-1", name: "test-name-1" } as SdkCipherView) + .mockReturnValueOnce({ id: "test-id-2", name: "test-name-2" } as SdkCipherView); + + jest + .spyOn(CipherView, "fromSdkCipherView") + .mockReturnValueOnce(expectedViews[0]) + .mockReturnValueOnce(expectedViews[1]); + + const result = await cipherEncryptionService.decryptManyLegacy(ciphers, userId); + + expect(result).toEqual(expectedViews); + expect(mockSdkClient.vault().ciphers().decrypt).toHaveBeenCalledTimes(2); + expect(CipherView.fromSdkCipherView).toHaveBeenCalledTimes(2); + }); + + it("should throw EmptyError when SDK is not available", async () => { + sdkService.userClient$ = jest.fn().mockReturnValue(of(null)) as any; + + await expect( + cipherEncryptionService.decryptManyLegacy([cipherObj], userId), + ).rejects.toThrow(); + + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to decrypt ciphers"), + ); + }); + }); + + describe("decryptMany", () => { + it("should decrypt multiple ciphers to list views", async () => { + const ciphers = [new Cipher(cipherData), new Cipher(cipherData)]; + + const expectedListViews = [ + { id: "list1", name: "List 1" } as CipherListView, + { id: "list2", name: "List 2" } as CipherListView, + ]; + + mockSdkClient.vault().ciphers().decrypt_list.mockReturnValue(expectedListViews); + + const result = await cipherEncryptionService.decryptMany(ciphers, userId); + + expect(result).toEqual(expectedListViews); + expect(mockSdkClient.vault().ciphers().decrypt_list).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: cipherData.id }), + expect.objectContaining({ id: cipherData.id }), + ]), + ); + }); + + it("should throw EmptyError when SDK is not available", async () => { + sdkService.userClient$ = jest.fn().mockReturnValue(of(null)) as any; + + await expect(cipherEncryptionService.decryptMany([cipherObj], userId)).rejects.toThrow(); + + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to decrypt cipher list"), + ); + }); + }); + + describe("decryptAttachmentContent", () => { + it("should decrypt attachment content successfully", async () => { + const cipher = new Cipher(cipherData); + const attachment = new AttachmentView(cipher.attachments![0]); + const encryptedContent = new Uint8Array([1, 2, 3, 4]); + const expectedDecryptedContent = new Uint8Array([5, 6, 7, 8]); + + jest.spyOn(cipher, "toSdkCipher").mockReturnValue({ id: "id" } as SdkCipher); + jest.spyOn(attachment, "toSdkAttachmentView").mockReturnValue({ id: "a1" } as SdkAttachment); + mockSdkClient.vault().attachments().decrypt_buffer.mockReturnValue(expectedDecryptedContent); + + const result = await cipherEncryptionService.decryptAttachmentContent( + cipher, + attachment, + encryptedContent, + userId, + ); + + expect(result).toEqual(expectedDecryptedContent); + expect(cipher.toSdkCipher).toHaveBeenCalled(); + expect(attachment.toSdkAttachmentView).toHaveBeenCalled(); + expect(mockSdkClient.vault().attachments().decrypt_buffer).toHaveBeenCalledWith( + { id: "id" }, + { id: "a1" }, + encryptedContent, + ); + }); + }); +}); diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts new file mode 100644 index 00000000000..2c57df6f5bb --- /dev/null +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -0,0 +1,190 @@ +import { EMPTY, catchError, firstValueFrom, map } from "rxjs"; + +import { CipherListView } from "@bitwarden/sdk-internal"; + +import { LogService } from "../../platform/abstractions/log.service"; +import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; +import { UserId } from "../../types/guid"; +import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; +import { CipherType } from "../enums"; +import { Cipher } from "../models/domain/cipher"; +import { AttachmentView } from "../models/view/attachment.view"; +import { CipherView } from "../models/view/cipher.view"; +import { Fido2CredentialView } from "../models/view/fido2-credential.view"; + +export class DefaultCipherEncryptionService implements CipherEncryptionService { + constructor( + private sdkService: SdkService, + private logService: LogService, + ) {} + + async decrypt(cipher: Cipher, userId: UserId): Promise { + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + const sdkCipherView = ref.value.vault().ciphers().decrypt(cipher.toSdkCipher()); + + const clientCipherView = CipherView.fromSdkCipherView(sdkCipherView)!; + + // Decrypt Fido2 credentials if available + if ( + clientCipherView.type === CipherType.Login && + sdkCipherView.login?.fido2Credentials?.length + ) { + const fido2CredentialViews = ref.value + .vault() + .ciphers() + .decrypt_fido2_credentials(sdkCipherView); + + // TEMPORARY: Manually decrypt the keyValue for Fido2 credentials + // since we don't currently use the SDK for Fido2 Authentication. + const decryptedKeyValue = ref.value + .vault() + .ciphers() + .decrypt_fido2_private_key(sdkCipherView); + + clientCipherView.login.fido2Credentials = fido2CredentialViews + .map((f) => { + const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; + + return { + ...view, + keyValue: decryptedKeyValue, + }; + }) + .filter((view): view is Fido2CredentialView => view !== undefined); + } + + return clientCipherView; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to decrypt cipher ${error}`); + return EMPTY; + }), + ), + ); + } + + decryptManyLegacy(ciphers: Cipher[], userId: UserId): Promise { + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + + return ciphers.map((cipher) => { + const sdkCipherView = ref.value.vault().ciphers().decrypt(cipher.toSdkCipher()); + const clientCipherView = CipherView.fromSdkCipherView(sdkCipherView)!; + + // Handle FIDO2 credentials if present + if ( + clientCipherView.type === CipherType.Login && + sdkCipherView.login?.fido2Credentials?.length + ) { + const fido2CredentialViews = ref.value + .vault() + .ciphers() + .decrypt_fido2_credentials(sdkCipherView); + + // TODO (PM-21259): Remove manual keyValue decryption for FIDO2 credentials. + // This is a temporary workaround until we can use the SDK for FIDO2 authentication. + const decryptedKeyValue = ref.value + .vault() + .ciphers() + .decrypt_fido2_private_key(sdkCipherView); + + clientCipherView.login.fido2Credentials = fido2CredentialViews + .map((f) => { + const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; + return { + ...view, + keyValue: decryptedKeyValue, + }; + }) + .filter((view): view is Fido2CredentialView => view !== undefined); + } + + return clientCipherView; + }); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to decrypt ciphers: ${error}`); + return EMPTY; + }), + ), + ); + } + + async decryptMany(ciphers: Cipher[], userId: UserId): Promise { + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK is undefined"); + } + + using ref = sdk.take(); + + return ref.value + .vault() + .ciphers() + .decrypt_list(ciphers.map((cipher) => cipher.toSdkCipher())); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to decrypt cipher list: ${error}`); + return EMPTY; + }), + ), + ); + } + + /** + * Decrypts an attachment's content from a response object. + * + * @param cipher The encrypted cipher object that owns the attachment + * @param attachment The encrypted attachment object + * @param encryptedContent The encrypted content as a Uint8Array + * @param userId The user ID whose key will be used for decryption + * + * @returns A promise that resolves to the decrypted content + */ + async decryptAttachmentContent( + cipher: Cipher, + attachment: AttachmentView, + encryptedContent: Uint8Array, + userId: UserId, + ): Promise { + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK is undefined"); + } + + using ref = sdk.take(); + + return ref.value + .vault() + .attachments() + .decrypt_buffer( + cipher.toSdkCipher(), + attachment.toSdkAttachmentView(), + encryptedContent, + ); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to decrypt cipher buffer: ${error}`); + return EMPTY; + }), + ), + ); + } +} diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index 9284718a063..af29d8263c6 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -118,9 +118,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); - const view = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + const view = await this.cipherService.decrypt(cipher, activeUserId); this.cleanupCipher(view); this.result.ciphers.push(view); } diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index ae408af421b..6ed4caa3f8d 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -10,7 +10,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { UserId } from "@bitwarden/common/types/guid"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -172,6 +172,8 @@ describe("VaultExportService", () => { let apiService: MockProxy; let fetchMock: jest.Mock; + const userId = "" as UserId; + beforeEach(() => { cryptoFunctionService = mock(); cipherService = mock(); @@ -185,7 +187,6 @@ describe("VaultExportService", () => { keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any)); - const userId = "" as UserId; const accountInfo: AccountInfo = { email: "", emailVerified: true, @@ -338,7 +339,9 @@ describe("VaultExportService", () => { cipherService.getAllDecrypted.mockResolvedValue([cipherView]); folderService.getAllDecryptedFromState.mockResolvedValue([]); - encryptService.decryptFileData.mockResolvedValue(new Uint8Array(255)); + cipherService.getDecryptedAttachmentBuffer.mockRejectedValue( + new Error("Error decrypting attachment"), + ); global.fetch = jest.fn(() => Promise.resolve({ @@ -356,13 +359,17 @@ describe("VaultExportService", () => { it("contains attachments with folders", async () => { const cipherData = new CipherData(); cipherData.id = "mock-id"; + const cipherRecord: Record = { + ["mock-id" as CipherId]: cipherData, + }; const cipherView = new CipherView(new Cipher(cipherData)); const attachmentView = new AttachmentView(new Attachment(new AttachmentData())); attachmentView.fileName = "mock-file-name"; cipherView.attachments = [attachmentView]; + cipherService.ciphers$.mockReturnValue(of(cipherRecord)); cipherService.getAllDecrypted.mockResolvedValue([cipherView]); folderService.getAllDecryptedFromState.mockResolvedValue([]); - encryptService.decryptFileData.mockResolvedValue(new Uint8Array(255)); + cipherService.getDecryptedAttachmentBuffer.mockResolvedValue(new Uint8Array(255)); global.fetch = jest.fn(() => Promise.resolve({ status: 200, diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index 8b66580d4cd..537585aac7e 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -12,14 +12,12 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; -import { UserId } from "@bitwarden/common/types/guid"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { Folder } from "@bitwarden/common/vault/models/domain/folder"; -import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { KdfConfigService, KeyService } from "@bitwarden/key-management"; @@ -118,8 +116,19 @@ export class IndividualVaultExportService const cipherFolder = attachmentsFolder.folder(cipher.id); for (const attachment of cipher.attachments) { const response = await this.downloadAttachment(cipher.id, attachment.id); - const decBuf = await this.decryptAttachment(cipher, attachment, response); - cipherFolder.file(attachment.fileName, decBuf); + + try { + const decBuf = await this.cipherService.getDecryptedAttachmentBuffer( + cipher.id as CipherId, + attachment, + response, + activeUserId, + ); + + cipherFolder.file(attachment.fileName, decBuf); + } catch { + throw new Error("Error decrypting attachment"); + } } } @@ -146,23 +155,6 @@ export class IndividualVaultExportService return response; } - private async decryptAttachment( - cipher: CipherView, - attachment: AttachmentView, - response: Response, - ) { - try { - const encBuf = await EncArrayBuffer.fromResponse(response); - const key = - attachment.key != null - ? attachment.key - : await this.keyService.getOrgKey(cipher.organizationId); - return await this.encryptService.decryptFileData(encBuf, key); - } catch { - throw new Error("Error decrypting attachment"); - } - } - private async getDecryptedExport( activeUserId: UserId, format: "json" | "csv", diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index fc46915c15d..4f30f299062 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -155,12 +155,9 @@ export class OrganizationVaultExportService .forEach(async (c) => { const cipher = new Cipher(new CipherData(c)); exportPromises.push( - this.cipherService - .getKeyForCipherKeyDecryption(cipher, activeUserId) - .then((key) => cipher.decrypt(key)) - .then((decCipher) => { - decCiphers.push(decCipher); - }), + this.cipherService.decrypt(cipher, activeUserId).then((decCipher) => { + decCiphers.push(decCipher); + }), ); }); } diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index 0fe358cd89b..da827addf67 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -80,6 +80,7 @@ describe("CipherAttachmentsComponent", () => { get: cipherServiceGet, saveAttachmentWithServer, getKeyForCipherKeyDecryption: () => Promise.resolve(null), + decrypt: jest.fn().mockResolvedValue(cipherView), }, }, { diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index 29a80c826c6..aa9769ec392 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -137,9 +137,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { this.organization = await this.getOrganization(); this.cipherDomain = await this.getCipher(this.cipherId); - this.cipher = await this.cipherDomain.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, this.activeUserId), - ); + this.cipher = await this.cipherService.decrypt(this.cipherDomain, this.activeUserId); // Update the initial state of the submit button if (this.submitBtn) { @@ -210,9 +208,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { ); // re-decrypt the cipher to update the attachments - this.cipher = await this.cipherDomain.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, this.activeUserId), - ); + this.cipher = await this.cipherService.decrypt(this.cipherDomain, this.activeUserId); // Reset reactive form and input element this.fileInput.nativeElement.value = ""; diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 98286e4bbb2..68eac4f0da2 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -3,7 +3,6 @@ import { inject, Injectable } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -21,13 +20,10 @@ function isSetEqual(a: Set, b: Set) { export class DefaultCipherFormService implements CipherFormService { private cipherService: CipherService = inject(CipherService); private accountService: AccountService = inject(AccountService); - private apiService: ApiService = inject(ApiService); async decryptCipher(cipher: Cipher): Promise { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - return await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); + return await this.cipherService.decrypt(cipher, activeUserId); } async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise { @@ -46,9 +42,7 @@ export class DefaultCipherFormService implements CipherFormService { // Creating a new cipher if (cipher.id == null) { savedCipher = await this.cipherService.createWithServer(encryptedCipher, config.admin); - return await savedCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId), - ); + return await this.cipherService.decrypt(savedCipher, activeUserId); } if (config.originalCipher == null) { @@ -100,8 +94,6 @@ export class DefaultCipherFormService implements CipherFormService { return null; } - return await savedCipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId), - ); + return await this.cipherService.decrypt(savedCipher, activeUserId); } } diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts index f621ca63101..8a4e962707d 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts @@ -6,15 +6,16 @@ import { BehaviorSubject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { StateProvider } from "@bitwarden/common/platform/state"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ToastService } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; import { PasswordRepromptService } from "../../services/password-reprompt.service"; @@ -51,6 +52,21 @@ describe("DownloadAttachmentComponent", () => { }, } as CipherView; + const ciphers$ = new BehaviorSubject({ + "5555-444-3333": { + id: "5555-444-3333", + attachments: [ + { + id: "222-3333-4444", + fileName: "encrypted-filename", + key: "encrypted-key", + }, + ], + }, + }); + + const getFeatureFlag = jest.fn().mockResolvedValue(false); + beforeEach(async () => { showToast.mockClear(); getAttachmentData.mockClear(); @@ -60,13 +76,22 @@ describe("DownloadAttachmentComponent", () => { imports: [DownloadAttachmentComponent], providers: [ { provide: EncryptService, useValue: mock() }, - { provide: KeyService, useValue: mock() }, { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: StateProvider, useValue: { activeUserId$ } }, { provide: ToastService, useValue: { showToast } }, { provide: ApiService, useValue: { getAttachmentData } }, { provide: FileDownloadService, useValue: { download } }, { provide: PasswordRepromptService, useValue: mock() }, + { + provide: ConfigService, + useValue: { + getFeatureFlag, + }, + }, + { + provide: CipherService, + useValue: { ciphers$: () => ciphers$, getDecryptedAttachmentBuffer: jest.fn() }, + }, ], }).compileComponents(); }); @@ -128,10 +153,12 @@ describe("DownloadAttachmentComponent", () => { }); }); - it("shows an error toast when EncArrayBuffer fails", async () => { + it("shows an error toast when getDecryptedAttachmentBuffer fails", async () => { getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" }); fetchMock.mockResolvedValue({ status: 200 }); - EncArrayBuffer.fromResponse = jest.fn().mockRejectedValue({}); + + const cipherService = TestBed.inject(CipherService) as jest.Mocked; + cipherService.getDecryptedAttachmentBuffer.mockRejectedValue(new Error()); await component.download(); diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.ts b/libs/vault/src/components/download-attachment/download-attachment.component.ts index e64777ebb8e..f06d6db582a 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.ts @@ -2,23 +2,19 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { NEVER, switchMap } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { EmergencyAccessId, OrganizationId } from "@bitwarden/common/types/guid"; -import { OrgKey } from "@bitwarden/common/types/key"; +import { CipherId, EmergencyAccessId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; @Component({ standalone: true, @@ -42,29 +38,14 @@ export class DownloadAttachmentComponent { /** When owners/admins can mange all items and when accessing from the admin console, use the admin endpoint */ @Input() admin?: boolean = false; - /** The organization key if the cipher is associated with one */ - private orgKey: OrgKey | null = null; - constructor( private i18nService: I18nService, private apiService: ApiService, private fileDownloadService: FileDownloadService, private toastService: ToastService, - private encryptService: EncryptService, private stateProvider: StateProvider, - private keyService: KeyService, - ) { - this.stateProvider.activeUserId$ - .pipe( - switchMap((userId) => (userId !== null ? this.keyService.orgKeys$(userId) : NEVER)), - takeUntilDestroyed(), - ) - .subscribe((data: Record | null) => { - if (data) { - this.orgKey = data[this.cipher.organizationId as OrganizationId]; - } - }); - } + private cipherService: CipherService, + ) {} /** Download the attachment */ download = async () => { @@ -100,9 +81,15 @@ export class DownloadAttachmentComponent { } try { - const encBuf = await EncArrayBuffer.fromResponse(response); - const key = this.attachment.key != null ? this.attachment.key : this.orgKey; - const decBuf = await this.encryptService.decryptFileData(encBuf, key); + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + + const decBuf = await this.cipherService.getDecryptedAttachmentBuffer( + this.cipher.id as CipherId, + this.attachment, + response, + userId, + ); + this.fileDownloadService.download({ fileName: this.attachment.fileName, blobData: decBuf,