1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 13:10:17 +00:00

decrypt attachment content using sdk

This commit is contained in:
gbubemismith
2025-04-18 19:05:02 -04:00
parent 97e1c4ad94
commit d48aa21bd6
11 changed files with 360 additions and 30 deletions

View File

@@ -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,
);
}
}

View File

@@ -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<Uint8Array> {
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);
}
}

View File

@@ -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<Uint8Array> {
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);
}
}

View File

@@ -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<CipherListView[]>;
/**
* 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<Uint8Array>;
}

View File

@@ -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,
);
});
});
});

View File

@@ -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<Uint8Array> {
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;
}),
),
);
}
}

View File

@@ -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<KdfConfigService>;
let accountService: MockProxy<AccountService>;
let apiService: MockProxy<ApiService>;
let configService: MockProxy<ConfigService>;
let cipherEncryptionService: MockProxy<CipherEncryptionService>;
let fetchMock: jest.Mock;
const userId = "" as UserId;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
cipherService = mock<CipherService>();
@@ -182,10 +188,11 @@ describe("VaultExportService", () => {
kdfConfigService = mock<KdfConfigService>();
accountService = mock<AccountService>();
apiService = mock<ApiService>();
configService = mock<ConfigService>();
cipherEncryptionService = mock<CipherEncryptionService>();
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<CipherId, CipherData> = {
["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", () => {

View File

@@ -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<Uint8Array> {
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

View File

@@ -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<AccountService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let apiService: MockProxy<ApiService>;
let configService: MockProxy<ConfigService>;
let cipherEncryptionService: MockProxy<CipherEncryptionService>;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
@@ -169,6 +173,8 @@ describe("VaultExportService", () => {
encryptService = mock<EncryptService>();
accountService = mock<AccountService>();
apiService = mock<ApiService>();
configService = mock<ConfigService>();
cipherEncryptionService = mock<CipherEncryptionService>();
kdfConfigService = mock<KdfConfigService>();
@@ -196,6 +202,8 @@ describe("VaultExportService", () => {
kdfConfigService,
accountService,
apiService,
configService,
cipherEncryptionService,
);
});

View File

@@ -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<PasswordRepromptService>() },
{
provide: ConfigService,
useValue: {
getFeatureFlag,
},
},
{ provide: CipherService, useValue: { ciphers$: () => ciphers$ } },
{ provide: CipherEncryptionService, useValue: mock<CipherEncryptionService>() },
],
}).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" });

View File

@@ -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<Uint8Array> {
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);
}
}