diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts index 067c63b2110..35becd4b0e7 100644 --- a/libs/common/src/vault/abstractions/cipher-encryption.service.ts +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -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; + /** + * 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; + /** * Decrypts a cipher using the SDK for the given userId. * diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index f027122993d..b743ae25838 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -397,7 +397,7 @@ describe("Cipher Service", () => { }); }); - describe("encryptWithCipherKey", () => { + describe("encryptCipherForRotation", () => { beforeEach(() => { jest.spyOn(cipherService, "encryptCipherWithCipherKey"); keyService.getOrgKey.mockReturnValue( @@ -534,6 +534,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", () => { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 1524e4e1b29..5302565ad9d 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1512,9 +1512,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); }), ); diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index 9e0cf62ed08..16e39421490 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -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 = { diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index 3547bafb4c9..2cef4ca1ca1 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -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 { + 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(encryptionContext.encryptedFor), + }; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to rotate cipher data: ${error}`); + return EMPTY; + }), + ), + ); + } + async decrypt(cipher: Cipher, userId: UserId): Promise { return firstValueFrom( this.sdkService.userClient$(userId).pipe( diff --git a/package-lock.json b/package-lock.json index 238479c1b80..de71ab1ab4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.231", + "@bitwarden/sdk-internal": "0.2.0-main.237", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4622,9 +4622,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.231", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.231.tgz", - "integrity": "sha512-fDKB/RFVvkRPWlhL/qhPAdJDjD1EpFjpEjjpY0v5QNGalh6NCztOr1OcMc4kvipPp4g+epZjs3SPN38K6R+7zw==", + "version": "0.2.0-main.237", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.237.tgz", + "integrity": "sha512-1psCagsmUo2QeIw/xFW/OCfSInl6Gu+LYldbdLuv1z26FurrgmAv8BejDaPRx006BRn0z0hn6TlZtteaZS762w==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index bcf4f326531..b40ccd0a68b 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.231", + "@bitwarden/sdk-internal": "0.2.0-main.237", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0",