mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
* PM-23890 WIP * add messaging service references * Revert "PM-19574 Browser Autofill overlay displays outdated data (#15624)" This reverts commit9eceaa3d4c. * add test * fix merge spacing * Revert "Revert "PM-19574 Browser Autofill overlay displays outdated data (#15624)"" This reverts commit0fc6ec4d0b.
742 lines
29 KiB
TypeScript
742 lines
29 KiB
TypeScript
import { mock } from "jest-mock-extended";
|
|
import { BehaviorSubject, filter, firstValueFrom, map, of } from "rxjs";
|
|
|
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
|
import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response";
|
|
// 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 { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management";
|
|
import { MessageSender } from "@bitwarden/messaging";
|
|
|
|
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
|
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
|
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 { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
|
import { EncString } from "../../key-management/crypto/models/enc-string";
|
|
import { UriMatchStrategy } from "../../models/domain/domain-service";
|
|
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
|
import { I18nService } from "../../platform/abstractions/i18n.service";
|
|
import { StateService } from "../../platform/abstractions/state.service";
|
|
import { Utils } from "../../platform/misc/utils";
|
|
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
|
|
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
|
import { ContainerService } from "../../platform/services/container.service";
|
|
import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid";
|
|
import { CipherKey, OrgKey, UserKey } from "../../types/key";
|
|
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
|
|
import { EncryptionContext } from "../abstractions/cipher.service";
|
|
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
|
import { SearchService } from "../abstractions/search.service";
|
|
import { FieldType } from "../enums";
|
|
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
|
|
import { CipherType } from "../enums/cipher-type";
|
|
import { CipherPermissionsApi } from "../models/api/cipher-permissions.api";
|
|
import { CipherData } from "../models/data/cipher.data";
|
|
import { Cipher } from "../models/domain/cipher";
|
|
import { CipherCreateRequest } from "../models/request/cipher-create.request";
|
|
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
|
|
import { CipherRequest } from "../models/request/cipher.request";
|
|
import { AttachmentView } from "../models/view/attachment.view";
|
|
import { CipherView } from "../models/view/cipher.view";
|
|
import { LoginUriView } from "../models/view/login-uri.view";
|
|
|
|
import { CipherService } from "./cipher.service";
|
|
|
|
const ENCRYPTED_TEXT = "This data has been encrypted";
|
|
function encryptText(clearText: string | Uint8Array) {
|
|
return Promise.resolve(new EncString(`${clearText} has been encrypted`));
|
|
}
|
|
const ENCRYPTED_BYTES = mock<EncArrayBuffer>();
|
|
|
|
const cipherData: CipherData = {
|
|
id: "id",
|
|
organizationId: "orgId",
|
|
folderId: "folderId",
|
|
edit: true,
|
|
viewPassword: true,
|
|
organizationUseTotp: true,
|
|
favorite: false,
|
|
revisionDate: "2022-01-31T12:00:00.000Z",
|
|
type: CipherType.Login,
|
|
name: "EncryptedString",
|
|
notes: "EncryptedString",
|
|
creationDate: "2022-01-01T12:00:00.000Z",
|
|
deletedDate: null,
|
|
permissions: new CipherPermissionsApi(),
|
|
key: "EncKey",
|
|
reprompt: CipherRepromptType.None,
|
|
login: {
|
|
uris: [
|
|
{ uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchStrategy.Domain },
|
|
],
|
|
username: "EncryptedString",
|
|
password: "EncryptedString",
|
|
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
|
|
totp: "EncryptedString",
|
|
autofillOnPageLoad: false,
|
|
},
|
|
passwordHistory: [{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }],
|
|
attachments: [
|
|
{ id: "a1", url: "url", size: "1100", sizeName: "1.1 KB", fileName: "file", key: "EncKey" },
|
|
{ id: "a2", url: "url", size: "1100", sizeName: "1.1 KB", fileName: "file", key: "EncKey" },
|
|
],
|
|
fields: [
|
|
{ name: "EncryptedString", value: "EncryptedString", type: FieldType.Text, linkedId: null },
|
|
{ name: "EncryptedString", value: "EncryptedString", type: FieldType.Hidden, linkedId: null },
|
|
],
|
|
};
|
|
const mockUserId = Utils.newGuid() as UserId;
|
|
let accountService: FakeAccountService;
|
|
|
|
describe("Cipher Service", () => {
|
|
const keyService = mock<KeyService>();
|
|
const stateService = mock<StateService>();
|
|
const autofillSettingsService = mock<AutofillSettingsService>();
|
|
const domainSettingsService = mock<DomainSettingsService>();
|
|
const apiService = mock<ApiService>();
|
|
const cipherFileUploadService = mock<CipherFileUploadService>();
|
|
const i18nService = mock<I18nService>();
|
|
const searchService = mock<SearchService>();
|
|
const encryptService = mock<EncryptService>();
|
|
const configService = mock<ConfigService>();
|
|
accountService = mockAccountServiceWith(mockUserId);
|
|
const logService = mock<LogService>();
|
|
const stateProvider = new FakeStateProvider(accountService);
|
|
const cipherEncryptionService = mock<CipherEncryptionService>();
|
|
const messageSender = mock<MessageSender>();
|
|
|
|
const userId = "TestUserId" as UserId;
|
|
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId;
|
|
|
|
let cipherService: CipherService;
|
|
let encryptionContext: EncryptionContext;
|
|
|
|
beforeEach(() => {
|
|
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
|
|
encryptService.encryptString.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT)));
|
|
|
|
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
|
|
|
cipherService = new CipherService(
|
|
keyService,
|
|
domainSettingsService,
|
|
apiService,
|
|
i18nService,
|
|
searchService,
|
|
stateService,
|
|
autofillSettingsService,
|
|
encryptService,
|
|
cipherFileUploadService,
|
|
configService,
|
|
stateProvider,
|
|
accountService,
|
|
logService,
|
|
cipherEncryptionService,
|
|
messageSender,
|
|
);
|
|
|
|
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.resetAllMocks();
|
|
});
|
|
|
|
describe("saveAttachmentRawWithServer()", () => {
|
|
it("should upload encrypted file contents with save attachments", async () => {
|
|
const fileName = "filename";
|
|
const fileData = new Uint8Array(10);
|
|
keyService.getOrgKey.mockReturnValue(
|
|
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
|
);
|
|
keyService.makeDataEncKey.mockReturnValue(
|
|
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32))),
|
|
);
|
|
|
|
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false));
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.CipherKeyEncryption)
|
|
.mockResolvedValue(false);
|
|
|
|
const spy = jest.spyOn(cipherFileUploadService, "upload");
|
|
|
|
await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData, userId);
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("createWithServer()", () => {
|
|
it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
|
|
const spy = jest
|
|
.spyOn(apiService, "postCipherAdmin")
|
|
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
|
await cipherService.createWithServer(encryptionContext, true);
|
|
const expectedObj = new CipherCreateRequest(encryptionContext);
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
expect(spy).toHaveBeenCalledWith(expectedObj);
|
|
});
|
|
|
|
it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
|
|
encryptionContext.cipher.organizationId = null!;
|
|
const spy = jest
|
|
.spyOn(apiService, "postCipher")
|
|
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
|
await cipherService.createWithServer(encryptionContext, true);
|
|
const expectedObj = new CipherRequest(encryptionContext);
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
expect(spy).toHaveBeenCalledWith(expectedObj);
|
|
});
|
|
|
|
it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
|
|
encryptionContext.cipher.collectionIds = ["123"];
|
|
const spy = jest
|
|
.spyOn(apiService, "postCipherCreate")
|
|
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
|
await cipherService.createWithServer(encryptionContext);
|
|
const expectedObj = new CipherCreateRequest(encryptionContext);
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
expect(spy).toHaveBeenCalledWith(expectedObj);
|
|
});
|
|
|
|
it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => {
|
|
const spy = jest
|
|
.spyOn(apiService, "postCipher")
|
|
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
|
await cipherService.createWithServer(encryptionContext);
|
|
const expectedObj = new CipherRequest(encryptionContext);
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
expect(spy).toHaveBeenCalledWith(expectedObj);
|
|
});
|
|
});
|
|
|
|
describe("updateWithServer()", () => {
|
|
it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
|
|
const spy = jest
|
|
.spyOn(apiService, "putCipherAdmin")
|
|
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
|
await cipherService.updateWithServer(encryptionContext, true);
|
|
const expectedObj = new CipherRequest(encryptionContext);
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
|
});
|
|
|
|
it("should call apiService.putCipher if cipher.edit is true", async () => {
|
|
encryptionContext.cipher.edit = true;
|
|
const spy = jest
|
|
.spyOn(apiService, "putCipher")
|
|
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
|
await cipherService.updateWithServer(encryptionContext);
|
|
const expectedObj = new CipherRequest(encryptionContext);
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
|
});
|
|
|
|
it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => {
|
|
encryptionContext.cipher.edit = false;
|
|
const spy = jest
|
|
.spyOn(apiService, "putPartialCipher")
|
|
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
|
await cipherService.updateWithServer(encryptionContext);
|
|
const expectedObj = new CipherPartialRequest(encryptionContext.cipher);
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
|
});
|
|
});
|
|
|
|
describe("encrypt", () => {
|
|
let cipherView: CipherView;
|
|
|
|
beforeEach(() => {
|
|
cipherView = new CipherView();
|
|
cipherView.type = CipherType.Login;
|
|
|
|
encryptService.unwrapSymmetricKey.mockResolvedValue(
|
|
new SymmetricCryptoKey(makeStaticByteArray(64)),
|
|
);
|
|
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
|
|
keyService.makeCipherKey.mockReturnValue(
|
|
Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey),
|
|
);
|
|
encryptService.encryptString.mockImplementation(encryptText);
|
|
encryptService.wrapSymmetricKey.mockResolvedValue(new EncString("Re-encrypted Cipher Key"));
|
|
|
|
jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true);
|
|
});
|
|
|
|
it("should call encrypt method of CipherEncryptionService when feature flag is true", async () => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
|
|
.mockResolvedValue(true);
|
|
cipherEncryptionService.encrypt.mockResolvedValue(encryptionContext);
|
|
|
|
const result = await cipherService.encrypt(cipherView, userId);
|
|
|
|
expect(result).toEqual(encryptionContext);
|
|
expect(cipherEncryptionService.encrypt).toHaveBeenCalledWith(cipherView, userId);
|
|
});
|
|
|
|
it("should call legacy encrypt when feature flag is false", async () => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
|
|
.mockResolvedValue(false);
|
|
|
|
jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher);
|
|
|
|
const result = await cipherService.encrypt(cipherView, userId);
|
|
|
|
expect(result).toEqual(encryptionContext);
|
|
expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call legacy encrypt when keys are provided", async () => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
|
|
.mockResolvedValue(true);
|
|
|
|
jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher);
|
|
|
|
const encryptKey = new SymmetricCryptoKey(new Uint8Array(32));
|
|
const decryptKey = new SymmetricCryptoKey(new Uint8Array(32));
|
|
|
|
let result = await cipherService.encrypt(cipherView, userId, encryptKey);
|
|
|
|
expect(result).toEqual(encryptionContext);
|
|
expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
|
|
|
|
result = await cipherService.encrypt(cipherView, userId, undefined, decryptKey);
|
|
expect(result).toEqual(encryptionContext);
|
|
expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
|
|
|
|
result = await cipherService.encrypt(cipherView, userId, encryptKey, decryptKey);
|
|
expect(result).toEqual(encryptionContext);
|
|
expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return the encrypting user id", async () => {
|
|
keyService.getOrgKey.mockReturnValue(
|
|
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
|
);
|
|
|
|
const { encryptedFor } = await cipherService.encrypt(cipherView, userId);
|
|
expect(encryptedFor).toEqual(userId);
|
|
});
|
|
|
|
describe("login encryption", () => {
|
|
it("should add a uri hash to login uris", async () => {
|
|
encryptService.hash.mockImplementation((value) => Promise.resolve(`${value} hash`));
|
|
cipherView.login.uris = [
|
|
{ uri: "uri", match: UriMatchStrategy.RegularExpression } as LoginUriView,
|
|
];
|
|
|
|
keyService.getOrgKey.mockReturnValue(
|
|
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
|
);
|
|
|
|
const { cipher } = await cipherService.encrypt(cipherView, userId);
|
|
|
|
expect(cipher.login.uris).toEqual([
|
|
{
|
|
uri: new EncString("uri has been encrypted"),
|
|
uriChecksum: new EncString("uri hash has been encrypted"),
|
|
match: UriMatchStrategy.RegularExpression,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("cipher.key", () => {
|
|
beforeEach(() => {
|
|
keyService.getOrgKey.mockReturnValue(
|
|
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
|
);
|
|
});
|
|
|
|
it("is null when feature flag is false", async () => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.CipherKeyEncryption)
|
|
.mockResolvedValue(false);
|
|
const { cipher } = await cipherService.encrypt(cipherView, userId);
|
|
|
|
expect(cipher.key).toBeNull();
|
|
});
|
|
|
|
describe("when feature flag is true", () => {
|
|
beforeEach(() => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.CipherKeyEncryption)
|
|
.mockResolvedValue(true);
|
|
});
|
|
|
|
it("is null when the cipher is not viewPassword", async () => {
|
|
cipherView.viewPassword = false;
|
|
|
|
const { cipher } = await cipherService.encrypt(cipherView, userId);
|
|
|
|
expect(cipher.key).toBeNull();
|
|
});
|
|
|
|
it("is defined when the cipher is viewPassword", async () => {
|
|
cipherView.viewPassword = true;
|
|
|
|
const { cipher } = await cipherService.encrypt(cipherView, userId);
|
|
|
|
expect(cipher.key).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("encryptCipherForRotation", () => {
|
|
beforeEach(() => {
|
|
jest.spyOn<any, string>(cipherService, "encryptCipherWithCipherKey");
|
|
keyService.getOrgKey.mockReturnValue(
|
|
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
|
|
);
|
|
});
|
|
|
|
it("is not called when feature flag is false", async () => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.CipherKeyEncryption)
|
|
.mockResolvedValue(false);
|
|
|
|
await cipherService.encrypt(cipherView, userId);
|
|
|
|
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe("when feature flag is true", () => {
|
|
beforeEach(() => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.CipherKeyEncryption)
|
|
.mockResolvedValue(true);
|
|
});
|
|
|
|
it("is called when cipher viewPassword is true", async () => {
|
|
cipherView.viewPassword = true;
|
|
|
|
await cipherService.encrypt(cipherView, userId);
|
|
|
|
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
|
|
});
|
|
|
|
it("is not called when cipher viewPassword is false and original cipher has no key", async () => {
|
|
cipherView.viewPassword = false;
|
|
|
|
await cipherService.encrypt(cipherView, userId, undefined, undefined, new Cipher());
|
|
|
|
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("is called when cipher viewPassword is false and original cipher has a key", async () => {
|
|
cipherView.viewPassword = false;
|
|
|
|
await cipherService.encrypt(
|
|
cipherView,
|
|
userId,
|
|
undefined,
|
|
undefined,
|
|
encryptionContext.cipher,
|
|
);
|
|
|
|
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getRotatedData", () => {
|
|
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
|
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
|
let decryptedCiphers: BehaviorSubject<Record<CipherId, CipherView>>;
|
|
let failedCiphers: BehaviorSubject<CipherView[]>;
|
|
let encryptedKey: EncString;
|
|
|
|
beforeEach(() => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.CipherKeyEncryption)
|
|
.mockResolvedValue(true);
|
|
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
|
|
|
|
searchService.indexedEntityId$.mockReturnValue(of(null));
|
|
|
|
stateService.getUserId.mockResolvedValue(mockUserId);
|
|
|
|
const keys = { userKey: originalUserKey } as CipherDecryptionKeys;
|
|
keyService.cipherDecryptionKeys$.mockReturnValue(of(keys));
|
|
|
|
const cipher1 = new CipherView(encryptionContext.cipher);
|
|
cipher1.id = "Cipher 1" as CipherId;
|
|
cipher1.organizationId = null;
|
|
const cipher2 = new CipherView(encryptionContext.cipher);
|
|
cipher2.id = "Cipher 2" as CipherId;
|
|
cipher2.organizationId = null;
|
|
|
|
decryptedCiphers = new BehaviorSubject({ [cipher1.id]: cipher1, [cipher2.id]: cipher2 });
|
|
jest
|
|
.spyOn(cipherService, "cipherViews$")
|
|
.mockImplementation((userId: UserId) =>
|
|
decryptedCiphers.pipe(map((ciphers) => Object.values(ciphers))),
|
|
);
|
|
|
|
failedCiphers = new BehaviorSubject<CipherView[]>([]);
|
|
jest
|
|
.spyOn(cipherService, "failedToDecryptCiphers$")
|
|
.mockImplementation((userId: UserId) => failedCiphers);
|
|
|
|
encryptService.unwrapSymmetricKey.mockResolvedValue(
|
|
new SymmetricCryptoKey(new Uint8Array(32)),
|
|
);
|
|
encryptedKey = new EncString("Re-encrypted Cipher Key");
|
|
encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey);
|
|
|
|
keyService.makeCipherKey.mockResolvedValue(
|
|
new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey,
|
|
);
|
|
});
|
|
|
|
it("returns re-encrypted user ciphers", async () => {
|
|
const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
|
|
|
expect(result[0]).toMatchObject({ id: "Cipher 1", key: "Re-encrypted Cipher Key" });
|
|
expect(result[1]).toMatchObject({ id: "Cipher 2", key: "Re-encrypted Cipher Key" });
|
|
});
|
|
|
|
it("throws if the original user key is null", async () => {
|
|
await expect(cipherService.getRotatedData(null!, newUserKey, mockUserId)).rejects.toThrow(
|
|
"Original user key is required to rotate ciphers",
|
|
);
|
|
});
|
|
|
|
it("throws if the new user key is null", async () => {
|
|
await expect(
|
|
cipherService.getRotatedData(originalUserKey, null!, mockUserId),
|
|
).rejects.toThrow("New user key is required to rotate ciphers");
|
|
});
|
|
|
|
it("throws if the user has any failed to decrypt ciphers", async () => {
|
|
const badCipher = new CipherView(encryptionContext.cipher);
|
|
badCipher.id = "Cipher 3";
|
|
badCipher.organizationId = null;
|
|
badCipher.decryptionFailure = true;
|
|
failedCiphers.next([badCipher]);
|
|
await expect(
|
|
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,
|
|
);
|
|
});
|
|
|
|
it("sends overlay update when cipherViews$ emits", async () => {
|
|
(cipherService.cipherViews$ as jest.Mock)?.mockRestore();
|
|
|
|
const decryptedView = new CipherView(encryptionContext.cipher);
|
|
jest.spyOn(cipherService, "getAllDecrypted").mockResolvedValue([decryptedView]);
|
|
|
|
const sendSpy = jest.spyOn(messageSender, "send");
|
|
|
|
await firstValueFrom(
|
|
cipherService
|
|
.cipherViews$(mockUserId)
|
|
.pipe(filter((cipherViews): cipherViews is CipherView[] => cipherViews != null)),
|
|
);
|
|
expect(sendSpy).toHaveBeenCalledWith("updateOverlayCiphers");
|
|
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("decrypt", () => {
|
|
it("should call decrypt method of CipherEncryptionService when feature flag is true", async () => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
|
|
.mockResolvedValue(true);
|
|
cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(encryptionContext.cipher));
|
|
|
|
const result = await cipherService.decrypt(encryptionContext.cipher, userId);
|
|
|
|
expect(result).toEqual(new CipherView(encryptionContext.cipher));
|
|
expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(
|
|
encryptionContext.cipher,
|
|
userId,
|
|
);
|
|
});
|
|
|
|
it("should call legacy decrypt when feature flag is false", async () => {
|
|
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
|
|
.mockResolvedValue(false);
|
|
cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey);
|
|
jest
|
|
.spyOn(encryptionContext.cipher, "decrypt")
|
|
.mockResolvedValue(new CipherView(encryptionContext.cipher));
|
|
|
|
const result = await cipherService.decrypt(encryptionContext.cipher, userId);
|
|
|
|
expect(result).toEqual(new CipherView(encryptionContext.cipher));
|
|
expect(encryptionContext.cipher.decrypt).toHaveBeenCalledWith(mockUserKey);
|
|
});
|
|
});
|
|
|
|
describe("getDecryptedAttachmentBuffer", () => {
|
|
const mockEncryptedContent = new Uint8Array([1, 2, 3]);
|
|
const mockDecryptedContent = new Uint8Array([4, 5, 6]);
|
|
|
|
it("should use SDK when feature flag is enabled", async () => {
|
|
const cipher = new Cipher(cipherData);
|
|
const attachment = new AttachmentView(cipher.attachments![0]);
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
|
|
.mockResolvedValue(true);
|
|
|
|
jest.spyOn(cipherService, "ciphers$").mockReturnValue(of({ [cipher.id]: cipherData }));
|
|
cipherEncryptionService.decryptAttachmentContent.mockResolvedValue(mockDecryptedContent);
|
|
const mockResponse = {
|
|
arrayBuffer: jest.fn().mockResolvedValue(mockEncryptedContent.buffer),
|
|
} as unknown as Response;
|
|
|
|
const result = await cipherService.getDecryptedAttachmentBuffer(
|
|
cipher.id as CipherId,
|
|
attachment,
|
|
mockResponse,
|
|
userId,
|
|
);
|
|
|
|
expect(result).toEqual(mockDecryptedContent);
|
|
expect(cipherEncryptionService.decryptAttachmentContent).toHaveBeenCalledWith(
|
|
cipher,
|
|
attachment,
|
|
mockEncryptedContent,
|
|
userId,
|
|
);
|
|
});
|
|
|
|
it("should use legacy decryption when feature flag is enabled", async () => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
|
|
.mockResolvedValue(false);
|
|
const cipher = new Cipher(cipherData);
|
|
const attachment = new AttachmentView(cipher.attachments![0]);
|
|
attachment.key = makeSymmetricCryptoKey(64);
|
|
|
|
const mockResponse = {
|
|
arrayBuffer: jest.fn().mockResolvedValue(mockEncryptedContent.buffer),
|
|
} as unknown as Response;
|
|
const mockEncBuf = {} as EncArrayBuffer;
|
|
EncArrayBuffer.fromResponse = jest.fn().mockResolvedValue(mockEncBuf);
|
|
encryptService.decryptFileData.mockResolvedValue(mockDecryptedContent);
|
|
|
|
const result = await cipherService.getDecryptedAttachmentBuffer(
|
|
cipher.id as CipherId,
|
|
attachment,
|
|
mockResponse,
|
|
userId,
|
|
);
|
|
|
|
expect(result).toEqual(mockDecryptedContent);
|
|
expect(encryptService.decryptFileData).toHaveBeenCalledWith(mockEncBuf, attachment.key);
|
|
});
|
|
});
|
|
|
|
describe("shareWithServer()", () => {
|
|
it("should use cipherEncryptionService to move the cipher when feature flag enabled", async () => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
|
|
.mockResolvedValue(true);
|
|
|
|
apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData));
|
|
|
|
const expectedCipher = new Cipher(cipherData);
|
|
expectedCipher.organizationId = orgId;
|
|
const cipherView = new CipherView(expectedCipher);
|
|
const collectionIds = ["collection1", "collection2"] as CollectionId[];
|
|
|
|
cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test
|
|
|
|
cipherEncryptionService.moveToOrganization.mockResolvedValue({
|
|
cipher: expectedCipher,
|
|
encryptedFor: userId,
|
|
});
|
|
|
|
await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId);
|
|
|
|
// Expect SDK usage
|
|
expect(cipherEncryptionService.moveToOrganization).toHaveBeenCalledWith(
|
|
cipherView,
|
|
orgId,
|
|
userId,
|
|
);
|
|
// Expect collectionIds to be assigned
|
|
expect(apiService.putShareCipher).toHaveBeenCalledWith(
|
|
cipherView.id,
|
|
expect.objectContaining({
|
|
cipher: expect.objectContaining({ organizationId: orgId }),
|
|
collectionIds: collectionIds,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("should use legacy encryption when feature flag disabled", async () => {
|
|
configService.getFeatureFlag
|
|
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
|
|
.mockResolvedValue(false);
|
|
|
|
apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData));
|
|
|
|
const expectedCipher = new Cipher(cipherData);
|
|
expectedCipher.organizationId = orgId;
|
|
const cipherView = new CipherView(expectedCipher);
|
|
const collectionIds = ["collection1", "collection2"] as CollectionId[];
|
|
|
|
cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test
|
|
|
|
const oldEncryptSharedSpy = jest
|
|
.spyOn(cipherService as any, "encryptSharedCipher")
|
|
.mockResolvedValue({
|
|
cipher: expectedCipher,
|
|
encryptedFor: userId,
|
|
});
|
|
|
|
await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId);
|
|
|
|
// Expect no SDK usage
|
|
expect(cipherEncryptionService.moveToOrganization).not.toHaveBeenCalled();
|
|
expect(oldEncryptSharedSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
organizationId: orgId,
|
|
collectionIds: collectionIds,
|
|
} as unknown as CipherView),
|
|
userId,
|
|
);
|
|
});
|
|
});
|
|
});
|