From d48aa21bd6d0976abfdb794b0bd55cf8dc4ab085 Mon Sep 17 00:00:00 2001 From: gbubemismith Date: Fri, 18 Apr 2025 19:05:02 -0400 Subject: [PATCH] decrypt attachment content using sdk --- .../vault/app/vault/attachments.component.ts | 6 ++ .../vault/components/attachments.component.ts | 62 ++++++++++++++----- .../src/vault/components/view.component.ts | 54 +++++++++++++--- .../abstractions/cipher-encryption.service.ts | 17 +++++ .../default-cipher-encryption.service.spec.ts | 35 +++++++++++ .../default-cipher-encryption.service.ts | 32 ++++++++++ .../individual-vault-export.service.spec.ts | 55 +++++++++++++++- .../individual-vault-export.service.ts | 29 ++++++++- .../src/services/vault-export.service.spec.ts | 8 +++ .../download-attachment.component.spec.ts | 50 +++++++++++++++ .../download-attachment.component.ts | 42 +++++++++++-- 11 files changed, 360 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/attachments.component.ts b/apps/desktop/src/vault/app/vault/attachments.component.ts index ea4f49b8431..f186b2c34f6 100644 --- a/apps/desktop/src/vault/app/vault/attachments.component.ts +++ b/apps/desktop/src/vault/app/vault/attachments.component.ts @@ -5,11 +5,13 @@ 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"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; @@ -33,6 +35,8 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, toastService: ToastService, + configService: ConfigService, + cipherEncryptionService: CipherEncryptionService, ) { super( cipherService, @@ -49,6 +53,8 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { billingAccountProfileStateService, accountService, toastService, + configService, + cipherEncryptionService, ); } } diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index 6b43f755c39..c939b6af344 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -1,21 +1,25 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; 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 { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; 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 +60,8 @@ export class AttachmentsComponent implements OnInit { protected billingAccountProfileStateService: BillingAccountProfileStateService, protected accountService: AccountService, protected toastService: ToastService, + protected configService: ConfigService, + protected cipherEncryptionService: CipherEncryptionService, ) {} async ngOnInit() { @@ -193,12 +199,9 @@ 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.decryptToBytes(encBuf, key); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const decBuf = await this.getDecryptedBuffer(response, attachment, activeUserId); + this.fileDownloadService.download({ fileName: attachment.fileName, blobData: decBuf, @@ -270,15 +273,11 @@ 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.decryptToBytes(encBuf, key); const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), ); + const decBuf = await this.getDecryptedBuffer(response, attachment, activeUserId); + this.cipherDomain = await this.cipherService.saveAttachmentRawWithServer( this.cipherDomain, attachment.fileName, @@ -341,4 +340,39 @@ export class AttachmentsComponent implements OnInit { protected async reupload(attachment: AttachmentView) { // TODO: This should be removed but is needed since we re-use the same template } + + private async getDecryptedBuffer( + response: Response, + attachment: AttachmentView, + userId: UserId, + ): Promise { + const useSdkDecryption = await this.configService.getFeatureFlag( + FeatureFlag.PM19941MigrateCipherDomainToSdk, + ); + + if (useSdkDecryption) { + const attachmentDomain = this.cipherDomain.attachments?.find((a) => a.id === attachment.id); + + const encArrayBuf = new Uint8Array(await response.arrayBuffer()); + return await this.cipherEncryptionService.decryptAttachmentContent( + this.cipherDomain, + attachmentDomain, + encArrayBuf, + userId, + ); + } + + const encBuf = await EncArrayBuffer.fromResponse(response); + const key = + attachment.key != null + ? attachment.key + : await firstValueFrom( + this.keyService + .orgKeys$(userId) + .pipe( + map((orgKeys) => orgKeys[this.cipher.organizationId as OrganizationId] as OrgKey), + ), + ); + return await this.encryptService.decryptToBytes(encBuf, key); + } } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 6b6f24f4217..64f848aa683 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -31,22 +31,27 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service" import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; 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, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; 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"; import { CipherType, FieldType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; 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"; @@ -137,6 +142,8 @@ export class ViewComponent implements OnDestroy, OnInit { private billingAccountProfileStateService: BillingAccountProfileStateService, protected toastService: ToastService, private cipherAuthorizationService: CipherAuthorizationService, + private configService: ConfigService, + private cipherEncryptionService: CipherEncryptionService, ) {} ngOnInit() { @@ -458,12 +465,8 @@ 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.decryptToBytes(encBuf, key); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const decBuf = await this.getDecryptedBuffer(response, attachment, activeUserId); this.fileDownloadService.download({ fileName: attachment.fileName, blobData: decBuf, @@ -564,4 +567,41 @@ export class ViewComponent implements OnDestroy, OnInit { } this.previousCipherId = this.cipherId; } + + private async getDecryptedBuffer( + response: Response, + attachment: AttachmentView, + userId: UserId, + ): Promise { + const useSdkDecryption = await this.configService.getFeatureFlag( + FeatureFlag.PM19941MigrateCipherDomainToSdk, + ); + + if (useSdkDecryption) { + const ciphersData = await firstValueFrom(this.cipherService.ciphers$(userId)); + const cipherDomain = new Cipher(ciphersData[this.cipher.id as CipherId]); + const attachmentDomain = cipherDomain.attachments?.find((a) => a.id === attachment.id); + + const encArrayBuf = new Uint8Array(await response.arrayBuffer()); + return await this.cipherEncryptionService.decryptAttachmentContent( + cipherDomain, + attachmentDomain, + encArrayBuf, + userId, + ); + } + + const encBuf = await EncArrayBuffer.fromResponse(response); + const key = + attachment.key != null + ? attachment.key + : await firstValueFrom( + this.keyService + .orgKeys$(userId) + .pipe( + map((orgKeys) => orgKeys[this.cipher.organizationId as OrganizationId] as OrgKey), + ), + ); + return await this.encryptService.decryptToBytes(encBuf, key); + } } diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts index d2772b500ee..bb9a7bd4843 100644 --- a/libs/common/src/vault/abstractions/cipher-encryption.service.ts +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -1,6 +1,7 @@ import { CipherListView } from "@bitwarden/sdk-internal"; import { UserId } from "../../types/guid"; +import { Attachment } from "../models/domain/attachment"; import { Cipher } from "../models/domain/cipher"; import { CipherView } from "../models/view/cipher.view"; @@ -26,4 +27,20 @@ export abstract class CipherEncryptionService { * @returns A promise that resolves to an array of decrypted cipher list views */ abstract decryptCipherList(ciphers: Cipher[], userId: UserId): Promise; + /** + * Decrypts an array buffer using the SDK for the given userId. + * + * @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 + */ + abstract decryptAttachmentContent( + cipher: Cipher, + attachment: Attachment, + encryptedContent: Uint8Array, + userId: UserId, + ): Promise; } 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 index 2251f5decba..1ec9c752ec4 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -6,6 +6,7 @@ import { Cipher as SdkCipher, CipherType as SdkCipherType, CipherView as SdkCipherView, + Attachment as SdkAttachment, } from "@bitwarden/sdk-internal"; import { mockEnc } from "../../../spec"; @@ -16,6 +17,7 @@ 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 { Attachment } from "../models/domain/attachment"; import { Cipher } from "../models/domain/cipher"; import { CipherView } from "../models/view/cipher.view"; import { Fido2CredentialView } from "../models/view/fido2-credential.view"; @@ -82,6 +84,9 @@ describe("DefaultCipherEncryptionService", () => { decrypt: jest.fn(), decrypt_fido2_credentials: jest.fn(), }), + attachments: jest.fn().mockReturnValue({ + decrypt_buffer: jest.fn(), + }), }), }; const mockRef = { @@ -232,4 +237,34 @@ describe("DefaultCipherEncryptionService", () => { expect(result.name).toBe("[error: cannot decrypt]"); }); }); + + describe("decryptAttachmentContent", () => { + it("should decrypt attachment content successfully", async () => { + const cipher = new Cipher(cipherData); + const attachment = new Attachment(cipherData.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, "toSdkAttachment").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.toSdkAttachment).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 index ce937bc8bbc..915cb06ecd0 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -7,6 +7,7 @@ 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 { Attachment } from "../models/domain/attachment"; import { Cipher } from "../models/domain/cipher"; import { CipherView } from "../models/view/cipher.view"; import { Fido2CredentialView } from "../models/view/fido2-credential.view"; @@ -102,4 +103,35 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { ), ); } + + /** + * {@inheritdoc} + */ + async decryptAttachmentContent( + cipher: Cipher, + attachment: Attachment, + 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.toSdkAttachment(), encryptedContent); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to decrypt cipher buffer: ${error}`); + return EMPTY; + }), + ), + ); + } } 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 15791ae04fb..ab6bb445dd3 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 @@ -8,9 +8,11 @@ import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; 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 { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; 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"; @@ -170,8 +172,12 @@ describe("VaultExportService", () => { let kdfConfigService: MockProxy; let accountService: MockProxy; let apiService: MockProxy; + let configService: MockProxy; + let cipherEncryptionService: MockProxy; let fetchMock: jest.Mock; + const userId = "" as UserId; + beforeEach(() => { cryptoFunctionService = mock(); cipherService = mock(); @@ -182,10 +188,11 @@ describe("VaultExportService", () => { kdfConfigService = mock(); accountService = mock(); apiService = mock(); + configService = mock(); + cipherEncryptionService = mock(); keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any)); - const userId = "" as UserId; const accountInfo: AccountInfo = { email: "", emailVerified: true, @@ -211,6 +218,7 @@ describe("VaultExportService", () => { kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG); encryptService.encrypt.mockResolvedValue(new EncString("encrypted")); apiService.getAttachmentData.mockResolvedValue(attachmentResponse); + configService.getFeatureFlag.mockResolvedValue(false); exportService = new IndividualVaultExportService( folderService, @@ -222,6 +230,8 @@ describe("VaultExportService", () => { kdfConfigService, accountService, apiService, + configService, + cipherEncryptionService, ); }); @@ -379,6 +389,47 @@ describe("VaultExportService", () => { const attachment = await zip.file("attachments/mock-id/mock-file-name")?.async("blob"); expect(attachment).toBeDefined(); }); + + it("calls decryptAttachmentContent when SDK flag is enabled", async () => { + const attachmentData = new AttachmentData(); + attachmentData.id = "mock-id"; + const cipherData = new CipherData(); + cipherData.id = "mock-id"; + cipherData.attachments = [attachmentData]; + const cipherRecord: Record = { + ["mock-id" as CipherId]: cipherData, + }; + const cipherView = new CipherView(new Cipher(cipherData)); + const attachmentView = new AttachmentView(new Attachment(attachmentData)); + attachmentView.fileName = "mock-file-name"; + cipherView.attachments = [attachmentView]; + cipherService.getAllDecrypted.mockResolvedValue([cipherView]); + cipherService.ciphers$.mockReturnValue(of(cipherRecord)); + cipherEncryptionService.decryptAttachmentContent.mockResolvedValue(new Uint8Array()); + folderService.getAllDecryptedFromState.mockResolvedValue([]); + configService.getFeatureFlag.mockResolvedValue(true); + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(255)), + }), + ) as any; + global.Request = jest.fn(() => {}) as any; + + const exportedVault = await exportService.getExport("zip"); + + expect(cipherEncryptionService.decryptAttachmentContent).toHaveBeenCalledWith( + expect.any(Cipher), + expect.any(Attachment), + expect.any(Uint8Array), + userId, + ); + expect(exportedVault.type).toBe("application/zip"); + const exportZip = exportedVault as ExportedVaultAsBlob; + const zip = await JSZip.loadAsync(exportZip.data); + const attachment = await zip.file("attachments/mock-id/mock-file-name")?.async("blob"); + expect(attachment).toBeDefined(); + }); }); describe("password protected export", () => { 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 d253ae8d0b1..3037b074ce7 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 @@ -8,11 +8,15 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common"; 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; 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"; @@ -51,6 +55,8 @@ export class IndividualVaultExportService kdfConfigService: KdfConfigService, private accountService: AccountService, private apiService: ApiService, + private configService: ConfigService, + private cipherEncryptionService: CipherEncryptionService, ) { super(pinService, encryptService, cryptoFunctionService, kdfConfigService); } @@ -116,7 +122,7 @@ 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); + const decBuf = await this.decryptAttachment(cipher, attachment, response, activeUserId); cipherFolder.file(attachment.fileName, decBuf); } } @@ -148,8 +154,27 @@ export class IndividualVaultExportService cipher: CipherView, attachment: AttachmentView, response: Response, - ) { + userId: UserId, + ): Promise { try { + const useSdkDecryption = await this.configService.getFeatureFlag( + FeatureFlag.PM19941MigrateCipherDomainToSdk, + ); + + if (useSdkDecryption) { + const ciphersData = await firstValueFrom(this.cipherService.ciphers$(userId)); + const cipherDomain = new Cipher(ciphersData[cipher.id as CipherId]); + const attachmentDomain = cipherDomain.attachments?.find((a) => a.id === attachment.id); + + const encArrayBuf = new Uint8Array(await response.arrayBuffer()); + return await this.cipherEncryptionService.decryptAttachmentContent( + cipherDomain, + attachmentDomain, + encArrayBuf, + userId, + ); + } + const encBuf = await EncArrayBuffer.fromResponse(response); const key = attachment.key != null diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts index a90f0a3ed7b..a99c0f590b4 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts @@ -7,9 +7,11 @@ import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; 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 { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; 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"; @@ -159,6 +161,8 @@ describe("VaultExportService", () => { let accountService: MockProxy; let kdfConfigService: MockProxy; let apiService: MockProxy; + let configService: MockProxy; + let cipherEncryptionService: MockProxy; beforeEach(() => { cryptoFunctionService = mock(); @@ -169,6 +173,8 @@ describe("VaultExportService", () => { encryptService = mock(); accountService = mock(); apiService = mock(); + configService = mock(); + cipherEncryptionService = mock(); kdfConfigService = mock(); @@ -196,6 +202,8 @@ describe("VaultExportService", () => { kdfConfigService, accountService, apiService, + configService, + cipherEncryptionService, ); }); 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..2040f5270cd 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 @@ -4,12 +4,16 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; 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 { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +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"; @@ -51,6 +55,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(); @@ -67,6 +86,14 @@ describe("DownloadAttachmentComponent", () => { { provide: ApiService, useValue: { getAttachmentData } }, { provide: FileDownloadService, useValue: { download } }, { provide: PasswordRepromptService, useValue: mock() }, + { + provide: ConfigService, + useValue: { + getFeatureFlag, + }, + }, + { provide: CipherService, useValue: { ciphers$: () => ciphers$ } }, + { provide: CipherEncryptionService, useValue: mock() }, ], }).compileComponents(); }); @@ -114,6 +141,29 @@ describe("DownloadAttachmentComponent", () => { expect(download).toHaveBeenCalledWith({ blobData: undefined, fileName: attachment.fileName }); }); + it("calls file download service with SDK decryption when SDK flag is enabled", async () => { + getFeatureFlag.mockResolvedValue(true); + getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" }); + fetchMock.mockResolvedValue({ + status: 200, + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)), + }); + + const cipherEncryptionService = TestBed.inject(CipherEncryptionService); + const decryptAttachmentContent = jest + .spyOn(cipherEncryptionService, "decryptAttachmentContent") + .mockResolvedValue(new Uint8Array()); + + await component.download(); + + expect(getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk); + expect(decryptAttachmentContent).toHaveBeenCalled(); + expect(download).toHaveBeenCalledWith({ + blobData: new Uint8Array(), + fileName: attachment.fileName, + }); + }); + describe("errors", () => { it("shows an error toast when fetch fails", async () => { getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" }); 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 812051775f8..f756014d616 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.ts @@ -3,18 +3,23 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { NEVER, switchMap } from "rxjs"; +import { NEVER, firstValueFrom, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; 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 { EmergencyAccessId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherId, EmergencyAccessId, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; 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"; @@ -50,6 +55,9 @@ export class DownloadAttachmentComponent { private encryptService: EncryptService, private stateProvider: StateProvider, private keyService: KeyService, + private configService: ConfigService, + private cipherService: CipherService, + private cipherEncryptionService: CipherEncryptionService, ) { this.stateProvider.activeUserId$ .pipe( @@ -95,9 +103,8 @@ 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.decryptToBytes(encBuf, key); + const decBuf = await this.getDecryptedBuffer(response); + this.fileDownloadService.download({ fileName: this.attachment.fileName, blobData: decBuf, @@ -112,4 +119,29 @@ export class DownloadAttachmentComponent { }); } }; + + private async getDecryptedBuffer(response: Response): Promise { + const useSdkDecryption = await this.configService.getFeatureFlag( + FeatureFlag.PM19941MigrateCipherDomainToSdk, + ); + + if (useSdkDecryption) { + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + const ciphersData = await firstValueFrom(this.cipherService.ciphers$(userId)); + const cipherDomain = new Cipher(ciphersData[this.cipher.id as CipherId]); + const attachmentDomain = cipherDomain.attachments?.find((a) => a.id === this.attachment.id); + + const encArrayBuf = new Uint8Array(await response.arrayBuffer()); + return await this.cipherEncryptionService.decryptAttachmentContent( + cipherDomain, + attachmentDomain, + encArrayBuf, + userId, + ); + } + + const encBuf = await EncArrayBuffer.fromResponse(response); + const key = this.attachment.key != null ? this.attachment.key : this.orgKey; + return await this.encryptService.decryptToBytes(encBuf, key); + } }