1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 17:43:22 +00:00

Merge branch 'vault/pm-27632/sdk-cipher-ops' into vault/pm-30303/sdk-cipher-ops-delete

This commit is contained in:
Nik Gilmore
2026-01-12 13:14:11 -08:00
committed by GitHub
398 changed files with 15711 additions and 1535 deletions

View File

@@ -0,0 +1,102 @@
import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response";
import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Cart } from "@bitwarden/pricing";
import {
BitwardenSubscription,
Storage,
SubscriptionStatus,
SubscriptionStatuses,
} from "@bitwarden/subscription";
export class BitwardenSubscriptionResponse extends BaseResponse {
status: SubscriptionStatus;
cart: Cart;
storage: Storage;
cancelAt?: Date;
canceled?: Date;
nextCharge?: Date;
suspension?: Date;
gracePeriod?: number;
constructor(response: any) {
super(response);
const status = this.getResponseProperty("Status");
if (
status !== SubscriptionStatuses.Incomplete &&
status !== SubscriptionStatuses.IncompleteExpired &&
status !== SubscriptionStatuses.Trialing &&
status !== SubscriptionStatuses.Active &&
status !== SubscriptionStatuses.PastDue &&
status !== SubscriptionStatuses.Canceled &&
status !== SubscriptionStatuses.Unpaid
) {
throw new Error(`Failed to parse invalid subscription status: ${status}`);
}
this.status = status;
this.cart = new CartResponse(this.getResponseProperty("Cart"));
this.storage = new StorageResponse(this.getResponseProperty("Storage"));
const suspension = this.getResponseProperty("Suspension");
if (suspension) {
this.suspension = new Date(suspension);
}
const gracePeriod = this.getResponseProperty("GracePeriod");
if (gracePeriod) {
this.gracePeriod = gracePeriod;
}
const nextCharge = this.getResponseProperty("NextCharge");
if (nextCharge) {
this.nextCharge = new Date(nextCharge);
}
const cancelAt = this.getResponseProperty("CancelAt");
if (cancelAt) {
this.cancelAt = new Date(cancelAt);
}
const canceled = this.getResponseProperty("Canceled");
if (canceled) {
this.canceled = new Date(canceled);
}
}
toDomain = (): BitwardenSubscription => {
switch (this.status) {
case SubscriptionStatuses.Incomplete:
case SubscriptionStatuses.IncompleteExpired:
case SubscriptionStatuses.PastDue:
case SubscriptionStatuses.Unpaid: {
return {
cart: this.cart,
storage: this.storage,
status: this.status,
suspension: this.suspension!,
gracePeriod: this.gracePeriod!,
};
}
case SubscriptionStatuses.Trialing:
case SubscriptionStatuses.Active: {
return {
cart: this.cart,
storage: this.storage,
status: this.status,
nextCharge: this.nextCharge!,
cancelAt: this.cancelAt,
};
}
case SubscriptionStatuses.Canceled: {
return {
cart: this.cart,
storage: this.storage,
status: this.status,
canceled: this.canceled!,
};
}
}
};
}

View File

@@ -0,0 +1,97 @@
import {
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Cart, CartItem, Discount } from "@bitwarden/pricing";
import { DiscountResponse } from "./discount.response";
export class CartItemResponse extends BaseResponse implements CartItem {
translationKey: string;
quantity: number;
cost: number;
discount?: Discount;
constructor(response: any) {
super(response);
this.translationKey = this.getResponseProperty("TranslationKey");
this.quantity = this.getResponseProperty("Quantity");
this.cost = this.getResponseProperty("Cost");
const discount = this.getResponseProperty("Discount");
if (discount) {
this.discount = discount;
}
}
}
class PasswordManagerCartItemResponse extends BaseResponse {
seats: CartItem;
additionalStorage?: CartItem;
constructor(response: any) {
super(response);
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
const additionalStorage = this.getResponseProperty("AdditionalStorage");
if (additionalStorage) {
this.additionalStorage = new CartItemResponse(additionalStorage);
}
}
}
class SecretsManagerCartItemResponse extends BaseResponse {
seats: CartItem;
additionalServiceAccounts?: CartItem;
constructor(response: any) {
super(response);
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts");
if (additionalServiceAccounts) {
this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts);
}
}
}
export class CartResponse extends BaseResponse implements Cart {
passwordManager: {
seats: CartItem;
additionalStorage?: CartItem;
};
secretsManager?: {
seats: CartItem;
additionalServiceAccounts?: CartItem;
};
cadence: SubscriptionCadence;
discount?: Discount;
estimatedTax: number;
constructor(response: any) {
super(response);
this.passwordManager = new PasswordManagerCartItemResponse(
this.getResponseProperty("PasswordManager"),
);
const secretsManager = this.getResponseProperty("SecretsManager");
if (secretsManager) {
this.secretsManager = new SecretsManagerCartItemResponse(secretsManager);
}
const cadence = this.getResponseProperty("Cadence");
if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) {
throw new Error(`Failed to parse invalid cadence: ${cadence}`);
}
this.cadence = cadence;
const discount = this.getResponseProperty("Discount");
if (discount) {
this.discount = new DiscountResponse(discount);
}
this.estimatedTax = this.getResponseProperty("EstimatedTax");
}
}

View File

@@ -0,0 +1,18 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing";
export class DiscountResponse extends BaseResponse implements Discount {
type: DiscountType;
value: number;
constructor(response: any) {
super(response);
const type = this.getResponseProperty("Type");
if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) {
throw new Error(`Failed to parse invalid discount type: ${type}`);
}
this.type = type;
this.value = this.getResponseProperty("Value");
}
}

View File

@@ -0,0 +1,16 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Storage } from "@bitwarden/subscription";
export class StorageResponse extends BaseResponse implements Storage {
available: number;
used: number;
readableUsed: string;
constructor(response: any) {
super(response);
this.available = this.getResponseProperty("Available");
this.used = this.getResponseProperty("Used");
this.readableUsed = this.getResponseProperty("ReadableUsed");
}
}

View File

@@ -31,6 +31,8 @@ export enum FeatureFlag {
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
PM26462_Milestone_3 = "pm-26462-milestone-3",
PM23341_Milestone_2 = "pm-23341-milestone-2",
PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page",
PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -54,7 +56,6 @@ export enum FeatureFlag {
/* DIRT */
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
PhishingDetection = "phishing-detection",
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
/* Vault */
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
@@ -117,7 +118,6 @@ export const DefaultFeatureFlagValue = {
/* DIRT */
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
[FeatureFlag.PhishingDetection]: FALSE,
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
/* Vault */
[FeatureFlag.CipherKeyEncryption]: FALSE,
@@ -141,6 +141,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
[FeatureFlag.PM26462_Milestone_3]: FALSE,
[FeatureFlag.PM23341_Milestone_2]: FALSE,
[FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE,
[FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,

View File

@@ -30,7 +30,7 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> {
if (salt == null) {
const bytes = await this.cryptoFunctionService.randomBytes(32);
salt = Utils.fromBufferToUtf8(bytes);
salt = Utils.fromBufferToUtf8(bytes.buffer as ArrayBuffer);
}
const material = await this.cryptoFunctionService.aesGenerateKey(bitLength);
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");

View File

@@ -330,6 +330,7 @@ export class ApiService implements ApiServiceAbstraction {
return new PaymentResponse(r);
}
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
postReinstatePremium(): Promise<any> {
return this.send("POST", "/accounts/reinstate-premium", null, true, false);
}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { SendResponse } from "../response/send.response";
import { SendFileData } from "./send-file.data";

View File

@@ -1,7 +1,7 @@
import { mock } from "jest-mock-extended";
import { mockContainerService, mockEnc } from "../../../../../spec";
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { SendAccessResponse } from "../response/send-access.response";
import { SendAccess } from "./send-access";

View File

@@ -3,7 +3,7 @@
import { EncString } from "../../../../key-management/crypto/models/enc-string";
import Domain from "../../../../platform/models/domain/domain-base";
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { SendAccessResponse } from "../response/send-access.response";
import { SendAccessView } from "../view/send-access.view";

View File

@@ -11,7 +11,7 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../../../platform/services/container.service";
import { UserKey } from "../../../../types/key";
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { SendData } from "../data/send.data";
import { Send } from "./send";

View File

@@ -8,7 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid";
import { EncString } from "../../../../key-management/crypto/models/enc-string";
import { Utils } from "../../../../platform/misc/utils";
import Domain from "../../../../platform/models/domain/domain-base";
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { SendData } from "../data/send.data";
import { SendView } from "../view/send.view";

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { SendFileApi } from "../api/send-file.api";
import { SendTextApi } from "../api/send-text.api";
import { Send } from "../domain/send";

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BaseResponse } from "../../../../models/response/base.response";
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { SendFileApi } from "../api/send-file.api";
import { SendTextApi } from "../api/send-text.api";

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BaseResponse } from "../../../../models/response/base.response";
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { SendFileApi } from "../api/send-file.api";
import { SendTextApi } from "../api/send-text.api";

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { View } from "../../../../models/view/view";
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { SendAccess } from "../domain/send-access";
import { SendFileView } from "./send-file.view";

View File

@@ -4,7 +4,7 @@ import { View } from "../../../../models/view/view";
import { Utils } from "../../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { DeepJsonify } from "../../../../types/deep-jsonify";
import { SendType } from "../../enums/send-type";
import { SendType } from "../../types/send-type";
import { Send } from "../domain/send";
import { SendFileView } from "./send-file.view";

View File

@@ -6,7 +6,6 @@ import {
FileUploadService,
} from "../../../platform/abstractions/file-upload/file-upload.service";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { SendType } from "../enums/send-type";
import { SendData } from "../models/data/send.data";
import { Send } from "../models/domain/send";
import { SendAccessRequest } from "../models/request/send-access.request";
@@ -16,6 +15,7 @@ import { SendFileDownloadDataResponse } from "../models/response/send-file-downl
import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response";
import { SendResponse } from "../models/response/send.response";
import { SendAccessView } from "../models/view/send-access.view";
import { SendType } from "../types/send-type";
import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction";
import { InternalSendService } from "./send.service.abstraction";

View File

@@ -24,13 +24,13 @@ import { ContainerService } from "../../../platform/services/container.service";
import { SelfHostedEnvironment } from "../../../platform/services/default-environment.service";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { SendType } from "../enums/send-type";
import { SendFileApi } from "../models/api/send-file.api";
import { SendTextApi } from "../models/api/send-text.api";
import { SendFileData } from "../models/data/send-file.data";
import { SendTextData } from "../models/data/send-text.data";
import { SendData } from "../models/data/send.data";
import { SendView } from "../models/view/send.view";
import { SendType } from "../types/send-type";
import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions";
import { SendStateProvider } from "./send-state.provider";

View File

@@ -16,7 +16,6 @@ import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { SendType } from "../enums/send-type";
import { SendData } from "../models/data/send.data";
import { Send } from "../models/domain/send";
import { SendFile } from "../models/domain/send-file";
@@ -24,6 +23,7 @@ import { SendText } from "../models/domain/send-text";
import { SendWithIdRequest } from "../models/request/send-with-id.request";
import { SendView } from "../models/view/send.view";
import { SEND_KDF_ITERATIONS } from "../send-kdf";
import { SendType } from "../types/send-type";
import { SendStateProvider } from "./send-state.provider.abstraction";
import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction";

View File

@@ -1,12 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EncString } from "../../../../key-management/crypto/models/enc-string";
import { SendType } from "../../enums/send-type";
import { SendTextApi } from "../../models/api/send-text.api";
import { SendTextData } from "../../models/data/send-text.data";
import { SendData } from "../../models/data/send.data";
import { Send } from "../../models/domain/send";
import { SendView } from "../../models/view/send.view";
import { SendType } from "../../types/send-type";
export function testSendViewData(id: string, name: string) {
const data = new SendView({} as any);

View File

@@ -0,0 +1,7 @@
export const SendFilterType = Object.freeze({
All: "all",
Text: "text",
File: "file",
} as const);
export type SendFilterType = (typeof SendFilterType)[keyof typeof SendFilterType];

View File

@@ -3,12 +3,14 @@ import { Observable } from "rxjs";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { CipherData } from "../models/data/cipher.data";
export abstract class CipherArchiveService {
abstract hasArchiveFlagEnabled$: Observable<boolean>;
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
abstract userCanArchive$(userId: UserId): Observable<boolean>;
abstract userHasPremium$(userId: UserId): Observable<boolean>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
}

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

@@ -114,6 +114,10 @@ export class CipherView implements View, InitializerMetadata {
return this.item?.subTitle;
}
get canBeArchived(): boolean {
return !this.isDeleted && !this.isArchived;
}
get hasPasswordHistory(): boolean {
return this.passwordHistory && this.passwordHistory.length > 0;
}

View File

@@ -343,6 +343,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

@@ -1,3 +1,7 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { of, firstValueFrom, BehaviorSubject } from "rxjs";

View File

@@ -18,6 +18,7 @@ import {
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { CipherArchiveService } from "../abstractions/cipher-archive.service";
import { CipherData } from "../models/data/cipher.data";
export class DefaultCipherArchiveService implements CipherArchiveService {
constructor(
@@ -84,15 +85,17 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
);
}
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]);
const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true);
const response = new ListResponse(r, CipherResponse);
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
// prevent mutating ciphers$ state
const localCiphers = structuredClone(currentCiphers);
for (const cipher of response.data) {
const localCipher = currentCiphers[cipher.id as CipherId];
const localCipher = localCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
@@ -102,18 +105,21 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.upsert(Object.values(currentCiphers), userId);
await this.cipherService.upsert(Object.values(localCiphers), userId);
return response.data[0];
}
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
const request = new CipherBulkUnarchiveRequest(Array.isArray(ids) ? ids : [ids]);
const r = await this.apiService.send("PUT", "/ciphers/unarchive", request, true, true);
const response = new ListResponse(r, CipherResponse);
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
// prevent mutating ciphers$ state
const localCiphers = structuredClone(currentCiphers);
for (const cipher of response.data) {
const localCipher = currentCiphers[cipher.id as CipherId];
const localCipher = localCiphers[cipher.id as CipherId];
if (localCipher == null) {
continue;
@@ -123,6 +129,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.upsert(Object.values(currentCiphers), userId);
await this.cipherService.upsert(Object.values(localCiphers), userId);
return response.data[0];
}
}

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

@@ -250,6 +250,38 @@ describe("DefaultCipherRiskService", () => {
expect.any(Object),
);
});
it("should filter out deleted Login ciphers", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
const activeCipher = new CipherView();
activeCipher.id = mockCipherId1;
activeCipher.type = CipherType.Login;
activeCipher.login = new LoginView();
activeCipher.login.password = "password1";
activeCipher.deletedDate = undefined;
const deletedCipher = new CipherView();
deletedCipher.id = mockCipherId2;
deletedCipher.type = CipherType.Login;
deletedCipher.login = new LoginView();
deletedCipher.login.password = "password2";
deletedCipher.deletedDate = new Date();
await cipherRiskService.computeRiskForCiphers([activeCipher, deletedCipher], mockUserId);
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
[
expect.objectContaining({
id: expect.anything(),
password: "password1",
}),
],
expect.any(Object),
);
});
});
describe("buildPasswordReuseMap", () => {
@@ -284,6 +316,41 @@ describe("DefaultCipherRiskService", () => {
]);
expect(result).toEqual(mockReuseMap);
});
it("should exclude deleted ciphers when building password reuse map", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const mockReuseMap = {
password1: 1,
};
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
const activeCipher = new CipherView();
activeCipher.id = mockCipherId1;
activeCipher.type = CipherType.Login;
activeCipher.login = new LoginView();
activeCipher.login.password = "password1";
activeCipher.deletedDate = undefined;
const deletedCipherWithSamePassword = new CipherView();
deletedCipherWithSamePassword.id = mockCipherId2;
deletedCipherWithSamePassword.type = CipherType.Login;
deletedCipherWithSamePassword.login = new LoginView();
deletedCipherWithSamePassword.login.password = "password1";
deletedCipherWithSamePassword.deletedDate = new Date();
const result = await cipherRiskService.buildPasswordReuseMap(
[activeCipher, deletedCipherWithSamePassword],
mockUserId,
);
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
expect.objectContaining({ password: "password1" }),
]);
expect(result).toEqual(mockReuseMap);
});
});
describe("computeCipherRiskForUser", () => {

View File

@@ -71,7 +71,6 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
passwordMap,
checkExposed,
});
return results[0];
}
@@ -103,7 +102,8 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
return (
cipher.type === CipherType.Login &&
cipher.login?.password != null &&
cipher.login.password !== ""
cipher.login.password !== "" &&
!cipher.isDeleted
);
})
.map(