1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 23:33:31 +00:00

[PM-22136] Implement SDK cipher encryption (#15337)

* [PM-22136] Update sdk cipher view map to support uknown uuid type

* [PM-22136] Add key to CipherView for copying to SdkCipherView for encryption

* [PM-22136] Add fromSdk* helpers to Cipher domain objects

* [PM-22136] Add toSdk* helpers to Cipher View objects

* [PM-22136] Add encrypt() to cipher encryption service

* [PM-22136] Add feature flag

* [PM-22136] Use new SDK encrypt method when feature flag is enabled

* [PM-22136] Filter out null/empty URIs

* [PM-22136] Change default value for cipher view arrays to []. See ADR-0014.

* [PM-22136] Keep encrypted key value on attachment so that it is passed to the SDK

* [PM-22136] Keep encrypted key value on CipherView so that it is passed to the SDK during encryption

* [PM-22136] Update failing attachment test

* [PM-22136] Update failing importer tests due to new default value for arrays

* [PM-22136] Update CipherView.fromJson to handle the prototype of EncString for the cipher key

* [PM-22136] Add tickets for followup work

* [PM-22136] Use new set_fido2_credentials SDK method instead

* [PM-22136] Fix missing prototype when decrypting Fido2Credentials

* [PM-22136] Fix test after sdk change

* [PM-22136] Update @bitwarden/sdk-internal version

* [PM-22136] Fix some strict typing errors

* [PM-23348] Migrate move cipher to org to SDK (#15567)

* [PM-23348] Add moveToOrganization method to cipher-encryption.service.ts

* [PM-23348] Use cipherEncryptionService.moveToOrganization in cipherService shareWithServer and shareManyWithServer methods

* [PM-23348] Update cipherFormService to use the shareWithServer() method instead of encrypt()

* [PM-23348] Fix typo

* [PM-23348] Add missing docs

* [PM-22136] Fix EncString import after merge with main
This commit is contained in:
Shane Melton
2025-07-21 23:27:01 -07:00
committed by GitHub
parent 81ee26733e
commit 391f540d1f
43 changed files with 1485 additions and 149 deletions

View File

@@ -1,20 +1,22 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { Fido2Credential } from "@bitwarden/common/vault/models/domain/fido2-credential";
import {
Fido2Credential,
Fido2Credential as SdkFido2Credential,
Cipher as SdkCipher,
CipherType as SdkCipherType,
CipherView as SdkCipherView,
CipherListView,
AttachmentView as SdkAttachmentView,
Fido2CredentialFullView,
} from "@bitwarden/sdk-internal";
import { mockEnc } from "../../../spec";
import { UriMatchStrategy } from "../../models/domain/domain-service";
import { LogService } from "../../platform/abstractions/log.service";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { UserId } from "../../types/guid";
import { UserId, CipherId, OrganizationId } from "../../types/guid";
import { CipherRepromptType, CipherType } from "../enums";
import { CipherPermissionsApi } from "../models/api/cipher-permissions.api";
import { CipherData } from "../models/data/cipher.data";
@@ -25,10 +27,15 @@ import { Fido2CredentialView } from "../models/view/fido2-credential.view";
import { DefaultCipherEncryptionService } from "./default-cipher-encryption.service";
const cipherId = "bdc4ef23-1116-477e-ae73-247854af58cb" as CipherId;
const orgId = "c5e9654f-6cc5-44c4-8e09-3d323522668c" as OrganizationId;
const folderId = "a3e9654f-6cc5-44c4-8e09-3d323522668c";
const userId = "59fbbb44-8cc8-4279-ab40-afc5f68704f4" as UserId;
const cipherData: CipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
id: cipherId,
organizationId: orgId,
folderId: folderId,
edit: true,
viewPassword: true,
organizationUseTotp: true,
@@ -78,13 +85,17 @@ describe("DefaultCipherEncryptionService", () => {
const sdkService = mock<SdkService>();
const logService = mock<LogService>();
let sdkCipherView: SdkCipherView;
let sdkCipher: SdkCipher;
const mockSdkClient = {
vault: jest.fn().mockReturnValue({
ciphers: jest.fn().mockReturnValue({
encrypt: jest.fn(),
set_fido2_credentials: jest.fn(),
decrypt: jest.fn(),
decrypt_list: jest.fn(),
decrypt_fido2_credentials: jest.fn(),
move_to_organization: jest.fn(),
}),
attachments: jest.fn().mockReturnValue({
decrypt_buffer: jest.fn(),
@@ -99,21 +110,25 @@ describe("DefaultCipherEncryptionService", () => {
take: jest.fn().mockReturnValue(mockRef),
};
const userId = "user-id" as UserId;
let cipherObj: Cipher;
let cipherViewObj: CipherView;
beforeEach(() => {
sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any;
cipherEncryptionService = new DefaultCipherEncryptionService(sdkService, logService);
cipherObj = new Cipher(cipherData);
cipherViewObj = new CipherView(cipherObj);
jest.spyOn(cipherObj, "toSdkCipher").mockImplementation(() => {
return { id: cipherData.id } as SdkCipher;
});
jest.spyOn(cipherViewObj, "toSdkCipherView").mockImplementation(() => {
return { id: cipherData.id } as SdkCipherView;
});
sdkCipherView = {
id: "test-id",
id: cipherId as string,
type: SdkCipherType.Login,
name: "test-name",
login: {
@@ -121,16 +136,211 @@ describe("DefaultCipherEncryptionService", () => {
password: "test-password",
},
} as SdkCipherView;
sdkCipher = {
id: cipherId,
type: SdkCipherType.Login,
name: "encrypted-name",
login: {
username: "encrypted-username",
password: "encrypted-password",
},
} as unknown as SdkCipher;
});
afterEach(() => {
jest.clearAllMocks();
});
describe("encrypt", () => {
it("should encrypt a cipher successfully", async () => {
const expectedCipher: Cipher = {
id: cipherId as string,
type: CipherType.Login,
name: "encrypted-name",
login: {
username: "encrypted-username",
password: "encrypted-password",
},
} as unknown as Cipher;
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
cipher: sdkCipher,
encryptedFor: userId,
});
jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher);
const result = await cipherEncryptionService.encrypt(cipherViewObj, userId);
expect(result).toBeDefined();
expect(result!.cipher).toEqual(expectedCipher);
expect(result!.encryptedFor).toBe(userId);
expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled();
expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledWith({ id: cipherData.id });
});
it("should encrypt FIDO2 credentials if present", async () => {
const fidoCredentialView = new Fido2CredentialView();
fidoCredentialView.credentialId = "credentialId";
cipherViewObj.login.fido2Credentials = [fidoCredentialView];
jest.spyOn(fidoCredentialView, "toSdkFido2CredentialFullView").mockImplementation(
() =>
({
credentialId: "credentialId",
}) as Fido2CredentialFullView,
);
jest.spyOn(cipherViewObj, "toSdkCipherView").mockImplementation(
() =>
({
id: cipherId as string,
login: {
fido2Credentials: undefined,
},
}) as unknown as SdkCipherView,
);
mockSdkClient
.vault()
.ciphers()
.set_fido2_credentials.mockReturnValue({
id: cipherId as string,
login: {
fido2Credentials: [
{
credentialId: "encrypted-credentialId",
},
],
},
});
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
cipher: sdkCipher,
encryptedFor: userId,
});
cipherObj.login!.fido2Credentials = [
{ credentialId: "encrypted-credentialId" } as unknown as Fido2Credential,
];
jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(cipherObj);
const result = await cipherEncryptionService.encrypt(cipherViewObj, userId);
expect(result).toBeDefined();
expect(result!.cipher.login!.fido2Credentials).toHaveLength(1);
// Ensure set_fido2_credentials was called with correct parameters
expect(mockSdkClient.vault().ciphers().set_fido2_credentials).toHaveBeenCalledWith(
expect.objectContaining({ id: cipherId }),
[{ credentialId: "credentialId" }],
);
// Encrypted fido2 credential should be in the cipher passed to encrypt
expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledWith(
expect.objectContaining({
id: cipherId,
login: { fido2Credentials: [{ credentialId: "encrypted-credentialId" }] },
}),
);
});
});
describe("moveToOrganization", () => {
it("should call the sdk method to move a cipher to an organization", async () => {
const expectedCipher: Cipher = {
id: cipherId as string,
type: CipherType.Login,
name: "encrypted-name",
organizationId: orgId,
login: {
username: "encrypted-username",
password: "encrypted-password",
},
} as unknown as Cipher;
mockSdkClient.vault().ciphers().move_to_organization.mockReturnValue({
id: cipherId,
organizationId: orgId,
});
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
cipher: sdkCipher,
encryptedFor: userId,
});
jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher);
const result = await cipherEncryptionService.moveToOrganization(cipherViewObj, orgId, userId);
expect(result).toBeDefined();
expect(result!.cipher).toEqual(expectedCipher);
expect(result!.encryptedFor).toBe(userId);
expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled();
expect(mockSdkClient.vault().ciphers().move_to_organization).toHaveBeenCalledWith(
{ id: cipherData.id },
orgId,
);
});
it("should re-encrypt any fido2 credentials when moving to an organization", async () => {
const mockSdkCredentialView = {
username: "username",
} as unknown as Fido2CredentialFullView;
const mockCredentialView = mock<Fido2CredentialView>();
mockCredentialView.toSdkFido2CredentialFullView.mockReturnValue(mockSdkCredentialView);
cipherViewObj.login.fido2Credentials = [mockCredentialView];
const expectedCipher: Cipher = {
id: cipherId as string,
type: CipherType.Login,
name: "encrypted-name",
organizationId: orgId,
login: {
username: "encrypted-username",
password: "encrypted-password",
fido2Credentials: [{ username: "encrypted-username" }],
},
} as unknown as Cipher;
mockSdkClient
.vault()
.ciphers()
.set_fido2_credentials.mockReturnValue({
id: cipherId as string,
login: {
fido2Credentials: [mockSdkCredentialView],
},
} as SdkCipherView);
mockSdkClient.vault().ciphers().move_to_organization.mockReturnValue({
id: cipherId,
organizationId: orgId,
});
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
cipher: sdkCipher,
encryptedFor: userId,
});
jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher);
const result = await cipherEncryptionService.moveToOrganization(cipherViewObj, orgId, userId);
expect(result).toBeDefined();
expect(result!.cipher).toEqual(expectedCipher);
expect(result!.encryptedFor).toBe(userId);
expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled();
expect(mockSdkClient.vault().ciphers().set_fido2_credentials).toHaveBeenCalledWith(
expect.objectContaining({ id: cipherId }),
expect.arrayContaining([mockSdkCredentialView]),
);
expect(mockSdkClient.vault().ciphers().move_to_organization).toHaveBeenCalledWith(
{ id: cipherData.id, login: { fido2Credentials: [mockSdkCredentialView] } },
orgId,
);
});
});
describe("decrypt", () => {
it("should decrypt a cipher successfully", async () => {
const expectedCipherView: CipherView = {
id: "test-id",
id: cipherId as string,
type: CipherType.Login,
name: "test-name",
login: {
@@ -168,12 +378,12 @@ describe("DefaultCipherEncryptionService", () => {
discoverable: mockEnc("true"),
creationDate: new Date("2023-01-01T12:00:00.000Z"),
},
] as unknown as Fido2Credential[];
] as unknown as SdkFido2Credential[];
sdkCipherView.login!.fido2Credentials = fido2Credentials;
const expectedCipherView: CipherView = {
id: "test-id",
id: cipherId,
type: CipherType.Login,
name: "test-name",
login: {
@@ -228,13 +438,15 @@ describe("DefaultCipherEncryptionService", () => {
it("should decrypt multiple ciphers successfully", async () => {
const ciphers = [new Cipher(cipherData), new Cipher(cipherData)];
const cipherId2 = "bdc4ef23-2222-477e-ae73-247854af58cb" as CipherId;
const expectedViews = [
{
id: "test-id-1",
id: cipherId as string,
name: "test-name-1",
} as CipherView,
{
id: "test-id-2",
id: cipherId2 as string,
name: "test-name-2",
} as CipherView,
];
@@ -242,8 +454,11 @@ describe("DefaultCipherEncryptionService", () => {
mockSdkClient
.vault()
.ciphers()
.decrypt.mockReturnValueOnce({ id: "test-id-1", name: "test-name-1" } as SdkCipherView)
.mockReturnValueOnce({ id: "test-id-2", name: "test-name-2" } as SdkCipherView);
.decrypt.mockReturnValueOnce({
id: cipherId,
name: "test-name-1",
} as unknown as SdkCipherView)
.mockReturnValueOnce({ id: cipherId2, name: "test-name-2" } as unknown as SdkCipherView);
jest
.spyOn(CipherView, "fromSdkCipherView")