1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-27 01:53:23 +00:00

Merge remote-tracking branch 'origin' into auth/pm-19877/notification-processing

This commit is contained in:
Patrick Pimentel
2025-08-04 15:46:17 -04:00
885 changed files with 17448 additions and 14057 deletions

View File

@@ -1,3 +1,4 @@
import { UserKey } from "@bitwarden/common/types/key";
import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherListView } from "@bitwarden/sdk-internal";
@@ -32,6 +33,18 @@ export abstract class CipherEncryptionService {
userId: UserId,
): Promise<EncryptionContext | undefined>;
/**
* Encrypts a cipher for a given userId with a new key for key rotation.
* @param model The cipher view to encrypt
* @param userId The user ID to initialize the SDK client with
* @param newKey The new key to use for re-encryption
*/
abstract encryptCipherForRotation(
model: CipherView,
userId: UserId,
newKey: UserKey,
): Promise<EncryptionContext | undefined>;
/**
* Decrypts a cipher using the SDK for the given userId.
*

View File

@@ -110,7 +110,7 @@ describe("Attachment", () => {
await attachment.decrypt(null, "", providedKey);
expect(keyService.getUserKeyWithLegacySupport).not.toHaveBeenCalled();
expect(keyService.getUserKey).not.toHaveBeenCalled();
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, providedKey);
});
@@ -126,11 +126,11 @@ describe("Attachment", () => {
it("gets the user's decryption key if required", async () => {
const userKey = mock<UserKey>();
keyService.getUserKeyWithLegacySupport.mockResolvedValue(userKey);
keyService.getUserKey.mockResolvedValue(userKey);
await attachment.decrypt(null, "", null);
expect(keyService.getUserKeyWithLegacySupport).toHaveBeenCalled();
expect(keyService.getUserKey).toHaveBeenCalled();
expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, userKey);
});
});

View File

@@ -80,9 +80,7 @@ export class Attachment extends Domain {
private async getKeyForDecryption(orgId: string) {
const keyService = Utils.getContainerService().getKeyService();
return orgId != null
? await keyService.getOrgKey(orgId)
: await keyService.getUserKeyWithLegacySupport();
return orgId != null ? await keyService.getOrgKey(orgId) : await keyService.getUserKey();
}
toAttachmentData(): AttachmentData {
@@ -128,8 +126,8 @@ export class Attachment extends Domain {
url: this.url,
size: this.size,
sizeName: this.sizeName,
fileName: this.fileName?.toJSON(),
key: this.key?.toJSON(),
fileName: this.fileName?.toSdk(),
key: this.key?.toSdk(),
};
}

View File

@@ -95,12 +95,12 @@ export class Card extends Domain {
*/
toSdkCard(): SdkCard {
return {
cardholderName: this.cardholderName?.toJSON(),
brand: this.brand?.toJSON(),
number: this.number?.toJSON(),
expMonth: this.expMonth?.toJSON(),
expYear: this.expYear?.toJSON(),
code: this.code?.toJSON(),
cardholderName: this.cardholderName?.toSdk(),
brand: this.brand?.toSdk(),
number: this.number?.toSdk(),
expMonth: this.expMonth?.toSdk(),
expYear: this.expYear?.toSdk(),
code: this.code?.toSdk(),
};
}

View File

@@ -11,6 +11,7 @@ import {
CipherRepromptType as SdkCipherRepromptType,
LoginLinkedIdType,
Cipher as SdkCipher,
EncString as SdkEncString,
} from "@bitwarden/sdk-internal";
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils";
@@ -1010,22 +1011,22 @@ describe("Cipher DTO", () => {
organizationId: "orgId",
folderId: "folderId",
collectionIds: [],
key: "EncryptedString",
name: "EncryptedString",
notes: "EncryptedString",
key: "EncryptedString" as SdkEncString,
name: "EncryptedString" as SdkEncString,
notes: "EncryptedString" as SdkEncString,
type: SdkCipherType.Login,
login: {
username: "EncryptedString",
password: "EncryptedString",
username: "EncryptedString" as SdkEncString,
password: "EncryptedString" as SdkEncString,
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
uris: [
{
uri: "EncryptedString",
uriChecksum: "EncryptedString",
uri: "EncryptedString" as SdkEncString,
uriChecksum: "EncryptedString" as SdkEncString,
match: UriMatchType.Domain,
},
],
totp: "EncryptedString",
totp: "EncryptedString" as SdkEncString,
autofillOnPageLoad: false,
fido2Credentials: undefined,
},
@@ -1049,35 +1050,35 @@ describe("Cipher DTO", () => {
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "file",
key: "EncKey",
fileName: "file" as SdkEncString,
key: "EncKey" as SdkEncString,
},
{
id: "a2",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "file",
key: "EncKey",
fileName: "file" as SdkEncString,
key: "EncKey" as SdkEncString,
},
],
fields: [
{
name: "EncryptedString",
value: "EncryptedString",
name: "EncryptedString" as SdkEncString,
value: "EncryptedString" as SdkEncString,
type: FieldType.Linked,
linkedId: LoginLinkedIdType.Username,
},
{
name: "EncryptedString",
value: "EncryptedString",
name: "EncryptedString" as SdkEncString,
value: "EncryptedString" as SdkEncString,
type: FieldType.Linked,
linkedId: LoginLinkedIdType.Password,
},
],
passwordHistory: [
{
password: "EncryptedString",
password: "EncryptedString" as SdkEncString,
lastUsedDate: "2022-01-31T12:00:00.000Z",
},
],

View File

@@ -348,9 +348,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
organizationId: this.organizationId ?? undefined,
folderId: this.folderId ?? undefined,
collectionIds: this.collectionIds ?? [],
key: this.key?.toJSON(),
name: this.name.toJSON(),
notes: this.notes?.toJSON(),
key: this.key?.toSdk(),
name: this.name.toSdk(),
notes: this.notes?.toSdk(),
type: this.type,
favorite: this.favorite ?? false,
organizationUseTotp: this.organizationUseTotp ?? false,

View File

@@ -158,18 +158,18 @@ export class Fido2Credential extends Domain {
*/
toSdkFido2Credential(): SdkFido2Credential {
return {
credentialId: this.credentialId?.toJSON(),
keyType: this.keyType.toJSON(),
keyAlgorithm: this.keyAlgorithm.toJSON(),
keyCurve: this.keyCurve.toJSON(),
keyValue: this.keyValue.toJSON(),
rpId: this.rpId.toJSON(),
userHandle: this.userHandle?.toJSON(),
userName: this.userName?.toJSON(),
counter: this.counter.toJSON(),
rpName: this.rpName?.toJSON(),
userDisplayName: this.userDisplayName?.toJSON(),
discoverable: this.discoverable?.toJSON(),
credentialId: this.credentialId?.toSdk(),
keyType: this.keyType.toSdk(),
keyAlgorithm: this.keyAlgorithm.toSdk(),
keyCurve: this.keyCurve.toSdk(),
keyValue: this.keyValue.toSdk(),
rpId: this.rpId.toSdk(),
userHandle: this.userHandle?.toSdk(),
userName: this.userName?.toSdk(),
counter: this.counter.toSdk(),
rpName: this.rpName?.toSdk(),
userDisplayName: this.userDisplayName?.toSdk(),
discoverable: this.discoverable?.toSdk(),
creationDate: this.creationDate.toISOString(),
};
}

View File

@@ -83,8 +83,8 @@ export class Field extends Domain {
*/
toSdkField(): SdkField {
return {
name: this.name?.toJSON(),
value: this.value?.toJSON(),
name: this.name?.toSdk(),
value: this.value?.toSdk(),
type: this.type,
// Safe type cast: client and SDK LinkedIdType enums have identical values
linkedId: this.linkedId as unknown as SdkLinkedIdType,

View File

@@ -1,7 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { makeEncString, makeSymmetricCryptoKey, mockEnc, mockFromJson } from "../../../../spec";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
@@ -73,13 +71,8 @@ describe("Folder", () => {
beforeEach(() => {
encryptService = mock<EncryptService>();
// Platform code is not migrated yet
encryptService.decryptToUtf8.mockImplementation(
(value: EncString, key: SymmetricCryptoKey, decryptTrace: string) => {
return Promise.resolve(value.data);
},
);
encryptService.decryptString.mockImplementation((value) => {
return Promise.resolve(value.data);
encryptService.decryptString.mockImplementation((_value, _key) => {
return Promise.resolve("encName");
});
});

View File

@@ -47,11 +47,11 @@ export class Folder extends Domain {
key: SymmetricCryptoKey,
encryptService: EncryptService,
): Promise<FolderView> {
const decrypted = await this.decryptObjWithKey(["name"], key, encryptService, Folder);
const view = new FolderView(decrypted);
view.name = decrypted.name;
return view;
const folderView = new FolderView();
folderView.id = this.id;
folderView.revisionDate = this.revisionDate;
folderView.name = await encryptService.decryptString(this.name, key);
return folderView;
}
static fromJSON(obj: Jsonify<Folder>) {

View File

@@ -175,24 +175,24 @@ export class Identity extends Domain {
*/
toSdkIdentity(): SdkIdentity {
return {
title: this.title?.toJSON(),
firstName: this.firstName?.toJSON(),
middleName: this.middleName?.toJSON(),
lastName: this.lastName?.toJSON(),
address1: this.address1?.toJSON(),
address2: this.address2?.toJSON(),
address3: this.address3?.toJSON(),
city: this.city?.toJSON(),
state: this.state?.toJSON(),
postalCode: this.postalCode?.toJSON(),
country: this.country?.toJSON(),
company: this.company?.toJSON(),
email: this.email?.toJSON(),
phone: this.phone?.toJSON(),
ssn: this.ssn?.toJSON(),
username: this.username?.toJSON(),
passportNumber: this.passportNumber?.toJSON(),
licenseNumber: this.licenseNumber?.toJSON(),
title: this.title?.toSdk(),
firstName: this.firstName?.toSdk(),
middleName: this.middleName?.toSdk(),
lastName: this.lastName?.toSdk(),
address1: this.address1?.toSdk(),
address2: this.address2?.toSdk(),
address3: this.address3?.toSdk(),
city: this.city?.toSdk(),
state: this.state?.toSdk(),
postalCode: this.postalCode?.toSdk(),
country: this.country?.toSdk(),
company: this.company?.toSdk(),
email: this.email?.toSdk(),
phone: this.phone?.toSdk(),
ssn: this.ssn?.toSdk(),
username: this.username?.toSdk(),
passportNumber: this.passportNumber?.toSdk(),
licenseNumber: this.licenseNumber?.toSdk(),
};
}

View File

@@ -97,8 +97,8 @@ export class LoginUri extends Domain {
*/
toSdkLoginUri(): SdkLoginUri {
return {
uri: this.uri?.toJSON(),
uriChecksum: this.uriChecksum?.toJSON(),
uri: this.uri?.toSdk(),
uriChecksum: this.uriChecksum?.toSdk(),
match: this.match,
};
}

View File

@@ -155,10 +155,10 @@ export class Login extends Domain {
toSdkLogin(): SdkLogin {
return {
uris: this.uris?.map((u) => u.toSdkLoginUri()),
username: this.username?.toJSON(),
password: this.password?.toJSON(),
username: this.username?.toSdk(),
password: this.password?.toSdk(),
passwordRevisionDate: this.passwordRevisionDate?.toISOString(),
totp: this.totp?.toJSON(),
totp: this.totp?.toSdk(),
autofillOnPageLoad: this.autofillOnPageLoad ?? undefined,
fido2Credentials: this.fido2Credentials?.map((f) => f.toSdkFido2Credential()),
};

View File

@@ -67,7 +67,7 @@ export class Password extends Domain {
*/
toSdkPasswordHistory(): PasswordHistory {
return {
password: this.password.toJSON(),
password: this.password.toSdk(),
lastUsedDate: this.lastUsedDate.toISOString(),
};
}

View File

@@ -80,9 +80,9 @@ export class SshKey extends Domain {
*/
toSdkSshKey(): SdkSshKey {
return {
privateKey: this.privateKey.toJSON(),
publicKey: this.publicKey.toJSON(),
fingerprint: this.keyFingerprint.toJSON(),
privateKey: this.privateKey.toSdk(),
publicKey: this.publicKey.toSdk(),
fingerprint: this.keyFingerprint.toSdk(),
};
}

View File

@@ -69,7 +69,7 @@ export class AttachmentView implements View {
size: this.size,
sizeName: this.sizeName,
fileName: this.fileName,
key: this.encryptedKey?.toJSON(),
key: this.encryptedKey?.toSdk(),
// TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete
decryptedKey: this.key ? this.key.toBase64() : null,
};

View File

@@ -331,7 +331,7 @@ export class CipherView implements View, InitializerMetadata {
creationDate: (this.creationDate ?? new Date()).toISOString(),
deletedDate: this.deletedDate?.toISOString(),
reprompt: this.reprompt ?? CipherRepromptType.None,
key: this.key?.toJSON(),
key: this.key?.toSdk(),
// Cipher type specific properties are set in the switch statement below
// CipherView initializes each with default constructors (undefined values)
// The SDK does not expect those undefined values and will throw exceptions

View File

@@ -119,7 +119,7 @@ describe("CipherAuthorizationService", () => {
cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(false);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled();
expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
done();
});
});
@@ -133,7 +133,7 @@ describe("CipherAuthorizationService", () => {
cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(true);
expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled();
expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
done();
});
});
@@ -198,6 +198,7 @@ describe("CipherAuthorizationService", () => {
cipherAuthorizationService.canDeleteCipher$(cipher, false).subscribe((result) => {
expect(result).toBe(false);
expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled();
done();
});
});
@@ -251,7 +252,7 @@ describe("CipherAuthorizationService", () => {
createMockCollection("col1", true),
createMockCollection("col2", false),
];
mockCollectionService.decryptedCollectionViews$.mockReturnValue(
mockCollectionService.decryptedCollections$.mockReturnValue(
of(allCollections as CollectionView[]),
);
@@ -270,7 +271,7 @@ describe("CipherAuthorizationService", () => {
createMockCollection("col1", false),
createMockCollection("col2", false),
];
mockCollectionService.decryptedCollectionViews$.mockReturnValue(
mockCollectionService.decryptedCollections$.mockReturnValue(
of(allCollections as CollectionView[]),
);

View File

@@ -1,11 +1,11 @@
import { map, Observable, of, shareReplay, switchMap } from "rxjs";
import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CollectionId } from "@bitwarden/common/types/guid";
import { getByIds } from "@bitwarden/common/platform/misc";
import { getUserId } from "../../auth/services/account.service";
import { CipherLike } from "../types/cipher-like";
@@ -125,8 +125,11 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
return of(true);
}
return this.organization$(cipher).pipe(
switchMap((organization) => {
return combineLatest([
this.organization$(cipher),
this.accountService.activeAccount$.pipe(getUserId),
]).pipe(
switchMap(([organization, userId]) => {
// Admins and custom users can always clone when in the Admin Console
if (
isAdminConsoleAction &&
@@ -136,9 +139,10 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
return of(true);
}
return this.collectionService
.decryptedCollectionViews$(cipher.collectionIds as CollectionId[])
.pipe(map((allCollections) => allCollections.some((collection) => collection.manage)));
return this.collectionService.decryptedCollections$(userId).pipe(
getByIds(cipher.collectionIds),
map((allCollections) => allCollections.some((collection) => collection.manage)),
);
}),
shareReplay({ bufferSize: 1, refCount: false }),
);

View File

@@ -14,7 +14,6 @@ import { makeStaticByteArray, makeSymmetricCryptoKey } from "../../../spec/utils
import { ApiService } from "../../abstractions/api.service";
import { AutofillSettingsService } from "../../autofill/services/autofill-settings.service";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { BulkEncryptService } from "../../key-management/crypto/abstractions/bulk-encrypt.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../models/domain/domain-service";
@@ -102,7 +101,6 @@ describe("Cipher Service", () => {
const i18nService = mock<I18nService>();
const searchService = mock<SearchService>();
const encryptService = mock<EncryptService>();
const bulkEncryptService = mock<BulkEncryptService>();
const configService = mock<ConfigService>();
accountService = mockAccountServiceWith(mockUserId);
const logService = mock<LogService>();
@@ -130,7 +128,6 @@ describe("Cipher Service", () => {
stateService,
autofillSettingsService,
encryptService,
bulkEncryptService,
cipherFileUploadService,
configService,
stateProvider,
@@ -397,7 +394,7 @@ describe("Cipher Service", () => {
});
});
describe("encryptWithCipherKey", () => {
describe("encryptCipherForRotation", () => {
beforeEach(() => {
jest.spyOn<any, string>(cipherService, "encryptCipherWithCipherKey");
keyService.getOrgKey.mockReturnValue(
@@ -534,6 +531,26 @@ describe("Cipher Service", () => {
cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId),
).rejects.toThrow("Cannot rotate ciphers when decryption failures are present");
});
it("uses the sdk to re-encrypt ciphers when feature flag is enabled", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
.mockResolvedValue(true);
cipherEncryptionService.encryptCipherForRotation.mockResolvedValue({
cipher: encryptionContext.cipher,
encryptedFor: mockUserId,
});
const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);
expect(result).toHaveLength(2);
expect(cipherEncryptionService.encryptCipherForRotation).toHaveBeenCalledWith(
expect.any(CipherView),
mockUserId,
newUserKey,
);
});
});
describe("decrypt", () => {
@@ -558,7 +575,6 @@ describe("Cipher Service", () => {
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
.mockResolvedValue(false);
cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
jest
.spyOn(encryptionContext.cipher, "decrypt")
.mockResolvedValue(new CipherView(encryptionContext.cipher));

View File

@@ -13,7 +13,6 @@ import { AccountService } from "../../auth/abstractions/account.service";
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { FeatureFlag } from "../../enums/feature-flag.enum";
import { BulkEncryptService } from "../../key-management/crypto/abstractions/bulk-encrypt.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../key-management/crypto/models/enc-string";
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
@@ -104,7 +103,6 @@ export class CipherService implements CipherServiceAbstraction {
private stateService: StateService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private encryptService: EncryptService,
private bulkEncryptService: BulkEncryptService,
private cipherFileUploadService: CipherFileUploadService,
private configService: ConfigService,
private stateProvider: StateProvider,
@@ -172,7 +170,7 @@ export class CipherService implements CipherServiceAbstraction {
return combineLatest([
this.encryptedCiphersState(userId).state$,
this.localData$(userId),
this.keyService.cipherDecryptionKeys$(userId, true),
this.keyService.cipherDecryptionKeys$(userId),
]).pipe(
filter(([ciphers, _, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet
switchMap(() => this.getAllDecrypted(userId)),
@@ -488,7 +486,7 @@ export class CipherService implements CipherServiceAbstraction {
return [decrypted, []];
}
const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId, true));
const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId));
if (keys == null || (keys.userKey == null && Object.keys(keys.orgKeys).length === 0)) {
// return early if there are no keys to decrypt with
return null;
@@ -506,17 +504,12 @@ export class CipherService implements CipherServiceAbstraction {
const allCipherViews = (
await Promise.all(
Object.entries(grouped).map(async ([orgId, groupedCiphers]) => {
if (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) {
return await this.bulkEncryptService.decryptItems(
groupedCiphers,
keys.orgKeys?.[orgId as OrganizationId] ?? keys.userKey,
);
} else {
return await this.encryptService.decryptItems(
groupedCiphers,
keys.orgKeys?.[orgId as OrganizationId] ?? keys.userKey,
);
}
const key = keys.orgKeys[orgId as OrganizationId] ?? keys.userKey;
return await Promise.all(
groupedCiphers.map(async (cipher) => {
return await cipher.decrypt(key);
}),
);
}),
)
)
@@ -684,12 +677,11 @@ export class CipherService implements CipherServiceAbstraction {
const ciphers = response.data.map((cr) => new Cipher(new CipherData(cr)));
const key = await this.keyService.getOrgKey(organizationId);
let decCiphers: CipherView[] = [];
if (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) {
decCiphers = await this.bulkEncryptService.decryptItems(ciphers, key);
} else {
decCiphers = await this.encryptService.decryptItems(ciphers, key);
}
const decCiphers: CipherView[] = await Promise.all(
ciphers.map(async (cipher) => {
return await cipher.decrypt(key);
}),
);
decCiphers.sort(this.getLocaleSortingFunction());
return decCiphers;
@@ -1474,7 +1466,7 @@ export class CipherService implements CipherServiceAbstraction {
async getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<UserKey | OrgKey> {
return (
(await this.keyService.getOrgKey(cipher.organizationId)) ||
((await this.keyService.getUserKeyWithLegacySupport(userId)) as UserKey)
((await this.keyService.getUserKey(userId)) as UserKey)
);
}
@@ -1512,9 +1504,16 @@ export class CipherService implements CipherServiceAbstraction {
if (userCiphers.length === 0) {
return encryptedCiphers;
}
const useSdkEncryption = await this.configService.getFeatureFlag(
FeatureFlag.PM22136_SdkCipherEncryption,
);
encryptedCiphers = await Promise.all(
userCiphers.map(async (cipher) => {
const encryptedCipher = await this.encrypt(cipher, userId, newUserKey, originalUserKey);
const encryptedCipher = useSdkEncryption
? await this.cipherEncryptionService.encryptCipherForRotation(cipher, userId, newUserKey)
: await this.encrypt(cipher, userId, newUserKey, originalUserKey);
return new CipherWithIdRequest(encryptedCipher);
}),
);
@@ -1599,7 +1598,7 @@ export class CipherService implements CipherServiceAbstraction {
// In the case of a cipher that is being shared with an organization, we want to decrypt the
// cipher key with the user's key and then re-encrypt it with the organization's key.
private async encryptSharedCipher(model: CipherView, userId: UserId): Promise<EncryptionContext> {
const keyForCipherKeyDecryption = await this.keyService.getUserKeyWithLegacySupport(userId);
const keyForCipherKeyDecryption = await this.keyService.getUserKey(userId);
return await this.encrypt(model, userId, null, keyForCipherKeyDecryption);
}
@@ -1674,12 +1673,12 @@ export class CipherService implements CipherServiceAbstraction {
const encBuf = await EncArrayBuffer.fromResponse(attachmentResponse);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$);
const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId.id);
const userKey = await this.keyService.getUserKey(activeUserId.id);
const decBuf = await this.encryptService.decryptFileData(encBuf, userKey);
let encKey: UserKey | OrgKey;
encKey = await this.keyService.getOrgKey(organizationId);
encKey ||= (await this.keyService.getUserKeyWithLegacySupport()) as UserKey;
encKey ||= (await this.keyService.getUserKey()) as UserKey;
const dataEncKey = await this.keyService.makeDataEncKey(encKey);

View File

@@ -1,6 +1,9 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserKey } from "@bitwarden/common/types/key";
import { Fido2Credential } from "@bitwarden/common/vault/models/domain/fido2-credential";
import {
Fido2Credential as SdkFido2Credential,
@@ -91,6 +94,7 @@ describe("DefaultCipherEncryptionService", () => {
vault: jest.fn().mockReturnValue({
ciphers: jest.fn().mockReturnValue({
encrypt: jest.fn(),
encrypt_cipher_for_rotation: jest.fn(),
set_fido2_credentials: jest.fn(),
decrypt: jest.fn(),
decrypt_list: jest.fn(),
@@ -247,6 +251,31 @@ describe("DefaultCipherEncryptionService", () => {
});
});
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({
cipher: sdkCipher,
encryptedFor: userId,
});
const newUserKey: UserKey = new SymmetricCryptoKey(
Utils.fromUtf8ToArray("00000000000000000000000000000000"),
) as UserKey;
const result = await cipherEncryptionService.encryptCipherForRotation(
cipherViewObj,
userId,
newUserKey,
);
expect(result).toBeDefined();
expect(mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation).toHaveBeenCalledWith(
expect.objectContaining({ id: cipherId }),
newUserKey.toBase64(),
);
});
});
describe("moveToOrganization", () => {
it("should call the sdk method to move a cipher to an organization", async () => {
const expectedCipher: Cipher = {

View File

@@ -1,5 +1,6 @@
import { EMPTY, catchError, firstValueFrom, map } from "rxjs";
import { UserKey } from "@bitwarden/common/types/key";
import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
CipherListView,
@@ -84,6 +85,39 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
);
}
async encryptCipherForRotation(
model: CipherView,
userId: UserId,
newKey: UserKey,
): Promise<EncryptionContext | undefined> {
return firstValueFrom(
this.sdkService.userClient$(userId).pipe(
map((sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
const sdkCipherView = this.toSdkCipherView(model, ref.value);
const encryptionContext = ref.value
.vault()
.ciphers()
.encrypt_cipher_for_rotation(sdkCipherView, newKey.toBase64());
return {
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
encryptedFor: asUuid<UserId>(encryptionContext.encryptedFor),
};
}),
catchError((error: unknown) => {
this.logService.error(`Failed to rotate cipher data: ${error}`);
return EMPTY;
}),
),
);
}
async decrypt(cipher: Cipher, userId: UserId): Promise<CipherView> {
return firstValueFrom(
this.sdkService.userClient$(userId).pipe(

View File

@@ -49,7 +49,6 @@ describe("Folder Service", () => {
keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
encryptService.decryptString.mockResolvedValue("DEC");
encryptService.decryptToUtf8.mockResolvedValue("DEC");
folderService = new FolderService(
keyService,