1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 18:33:50 +00:00

Added batch encrypt many method and used that in imports (#18266)

This commit is contained in:
SmithThe4th
2026-01-08 15:01:03 -05:00
committed by jaasen-livefront
parent b0162b4c91
commit bc1ba01b40
6 changed files with 154 additions and 7 deletions

View File

@@ -20,6 +20,16 @@ export abstract class CipherEncryptionService {
*/
abstract encrypt(model: CipherView, userId: UserId): Promise<EncryptionContext | undefined>;
/**
* Encrypts multiple ciphers using the SDK for the given userId.
*
* @param models The cipher views to encrypt
* @param userId The user ID to initialize the SDK client with
*
* @returns A promise that resolves to an array of encryption contexts
*/
abstract encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]>;
/**
* Move the cipher to the specified organization by re-encrypting its keys with the organization's key.
* The cipher.organizationId will be updated to the new organizationId.

View File

@@ -50,6 +50,15 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
keyForCipherKeyDecryption?: SymmetricCryptoKey,
originalCipher?: Cipher,
): Promise<EncryptionContext>;
/**
* Encrypts multiple ciphers for the given user.
*
* @param models The cipher views to encrypt
* @param userId The user ID to encrypt for
*
* @returns A promise that resolves to an array of encryption contexts
*/
abstract encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]>;
abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise<Field[]>;
abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise<Field>;
abstract get(id: string, userId: UserId): Promise<Cipher>;

View File

@@ -340,6 +340,24 @@ export class CipherService implements CipherServiceAbstraction {
}
}
async encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]> {
const sdkEncryptionEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM22136_SdkCipherEncryption,
);
if (sdkEncryptionEnabled) {
return await this.cipherEncryptionService.encryptMany(models, userId);
}
// Fallback to sequential encryption if SDK disabled
const results: EncryptionContext[] = [];
for (const model of models) {
const result = await this.encrypt(model, userId);
results.push(result);
}
return results;
}
async encryptAttachments(
attachmentsModel: AttachmentView[],
key: SymmetricCryptoKey,

View File

@@ -253,6 +253,68 @@ describe("DefaultCipherEncryptionService", () => {
});
});
describe("encryptMany", () => {
it("should encrypt multiple ciphers", async () => {
const cipherView2 = new CipherView(cipherObj);
cipherView2.name = "test-name-2";
const cipherView3 = new CipherView(cipherObj);
cipherView3.name = "test-name-3";
const ciphers = [cipherViewObj, cipherView2, cipherView3];
const expectedCipher1: Cipher = {
id: cipherId as string,
type: CipherType.Login,
name: "encrypted-name-1",
} as unknown as Cipher;
const expectedCipher2: Cipher = {
id: cipherId as string,
type: CipherType.Login,
name: "encrypted-name-2",
} as unknown as Cipher;
const expectedCipher3: Cipher = {
id: cipherId as string,
type: CipherType.Login,
name: "encrypted-name-3",
} as unknown as Cipher;
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
cipher: sdkCipher,
encryptedFor: userId,
});
jest
.spyOn(Cipher, "fromSdkCipher")
.mockReturnValueOnce(expectedCipher1)
.mockReturnValueOnce(expectedCipher2)
.mockReturnValueOnce(expectedCipher3);
const results = await cipherEncryptionService.encryptMany(ciphers, userId);
expect(results).toBeDefined();
expect(results.length).toBe(3);
expect(results[0].cipher).toEqual(expectedCipher1);
expect(results[1].cipher).toEqual(expectedCipher2);
expect(results[2].cipher).toEqual(expectedCipher3);
expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3);
expect(results[0].encryptedFor).toBe(userId);
expect(results[1].encryptedFor).toBe(userId);
expect(results[2].encryptedFor).toBe(userId);
});
it("should handle empty array", async () => {
const results = await cipherEncryptionService.encryptMany([], userId);
expect(results).toBeDefined();
expect(results.length).toBe(0);
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
});
});
describe("encryptCipherForRotation", () => {
it("should call the sdk method to encrypt the cipher with a new key for rotation", async () => {
mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation.mockReturnValue({

View File

@@ -51,6 +51,44 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
);
}
async encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]> {
if (!models || models.length === 0) {
return [];
}
return firstValueFrom(
this.sdkService.userClient$(userId).pipe(
map((sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
const results: EncryptionContext[] = [];
// TODO: https://bitwarden.atlassian.net/browse/PM-30580
// Replace this loop with a native SDK encryptMany method for better performance.
for (const model of models) {
const sdkCipherView = this.toSdkCipherView(model, ref.value);
const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView);
results.push({
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId,
});
}
return results;
}),
catchError((error: unknown) => {
this.logService.error(`Failed to encrypt ciphers in batch: ${error}`);
return EMPTY;
}),
),
);
}
async moveToOrganization(
model: CipherView,
organizationId: OrganizationId,

View File

@@ -374,10 +374,13 @@ export class ImportService implements ImportServiceAbstraction {
private async handleIndividualImport(importResult: ImportResult, userId: UserId) {
const request = new ImportCiphersRequest();
for (let i = 0; i < importResult.ciphers.length; i++) {
const c = await this.cipherService.encrypt(importResult.ciphers[i], userId);
request.ciphers.push(new CipherRequest(c));
const encryptedCiphers = await this.cipherService.encryptMany(importResult.ciphers, userId);
for (const encryptedCipher of encryptedCiphers) {
request.ciphers.push(new CipherRequest(encryptedCipher));
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (importResult.folders != null) {
@@ -400,11 +403,18 @@ export class ImportService implements ImportServiceAbstraction {
userId: UserId,
) {
const request = new ImportOrganizationCiphersRequest();
for (let i = 0; i < importResult.ciphers.length; i++) {
importResult.ciphers[i].organizationId = organizationId;
const c = await this.cipherService.encrypt(importResult.ciphers[i], userId);
request.ciphers.push(new CipherRequest(c));
// Set organization ID on all ciphers before batch encryption
importResult.ciphers.forEach((cipher) => {
cipher.organizationId = organizationId;
});
const encryptedCiphers = await this.cipherService.encryptMany(importResult.ciphers, userId);
for (const encryptedCipher of encryptedCiphers) {
request.ciphers.push(new CipherRequest(encryptedCipher));
}
if (importResult.collections != null) {
for (let i = 0; i < importResult.collections.length; i++) {
importResult.collections[i].organizationId = organizationId;