mirror of
https://github.com/bitwarden/browser
synced 2026-02-26 01:23:24 +00:00
Merge branch 'main' into km/new-mp-service-api
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
|
||||
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
|
||||
@@ -16,12 +14,12 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Flag indicating the collection has no active user or group assigned to it with CanManage permissions
|
||||
* In this case, the collection can be managed by admins/owners or custom users with appropriate permissions
|
||||
*/
|
||||
unmanaged: boolean;
|
||||
unmanaged: boolean = false;
|
||||
|
||||
/**
|
||||
* Flag indicating the user has been explicitly assigned to this Collection
|
||||
*/
|
||||
assigned: boolean;
|
||||
assigned: boolean = false;
|
||||
|
||||
constructor(response?: CollectionAccessDetailsResponse) {
|
||||
super(response);
|
||||
@@ -45,6 +43,10 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Returns true if the user can edit a collection (including user and group access) from the Admin Console.
|
||||
*/
|
||||
override canEdit(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
org?.canEditAnyCollection ||
|
||||
(this.unmanaged && org?.canEditUnmanagedCollections) ||
|
||||
@@ -56,6 +58,10 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Returns true if the user can delete a collection from the Admin Console.
|
||||
*/
|
||||
override canDelete(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return org?.canDeleteAnyCollection || super.canDelete(org);
|
||||
}
|
||||
|
||||
@@ -63,6 +69,10 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Whether the user can modify user access to this collection
|
||||
*/
|
||||
canEditUserAccess(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org)
|
||||
);
|
||||
@@ -72,6 +82,10 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Whether the user can modify group access to this collection
|
||||
*/
|
||||
canEditGroupAccess(org: Organization): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) ||
|
||||
this.canEdit(org)
|
||||
@@ -82,11 +96,13 @@ export class CollectionAdminView extends CollectionView {
|
||||
* Returns true if the user can view collection info and access in a read-only state from the Admin Console
|
||||
*/
|
||||
override canViewCollectionInfo(org: Organization | undefined): boolean {
|
||||
if (this.isUnassignedCollection) {
|
||||
if (this.isUnassignedCollection || this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
const isAdmin = org?.isAdmin ?? false;
|
||||
const permissions = org?.permissions.editAnyCollection ?? false;
|
||||
|
||||
return this.manage || org?.isAdmin || org?.permissions.editAnyCollection;
|
||||
return this.manage || isAdmin || permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
|
||||
import { Collection, CollectionType } from "./collection";
|
||||
import { Collection, CollectionType, CollectionTypes } from "./collection";
|
||||
import { CollectionAccessDetailsResponse } from "./collection.response";
|
||||
|
||||
export const NestingDelimiter = "/";
|
||||
|
||||
export class CollectionView implements View, ITreeNodeObject {
|
||||
id: string = null;
|
||||
organizationId: string = null;
|
||||
name: string = null;
|
||||
externalId: string = null;
|
||||
id: string | undefined;
|
||||
organizationId: string | undefined;
|
||||
name: string | undefined;
|
||||
externalId: string | undefined;
|
||||
// readOnly applies to the items within a collection
|
||||
readOnly: boolean = null;
|
||||
hidePasswords: boolean = null;
|
||||
manage: boolean = null;
|
||||
assigned: boolean = null;
|
||||
type: CollectionType = null;
|
||||
readOnly: boolean = false;
|
||||
hidePasswords: boolean = false;
|
||||
manage: boolean = false;
|
||||
assigned: boolean = false;
|
||||
type: CollectionType = CollectionTypes.SharedCollection;
|
||||
|
||||
constructor(c?: Collection | CollectionAccessDetailsResponse) {
|
||||
if (!c) {
|
||||
@@ -57,7 +55,11 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
* Returns true if the user can edit a collection (including user and group access) from the individual vault.
|
||||
* Does not include admin permissions - see {@link CollectionAdminView.canEdit}.
|
||||
*/
|
||||
canEdit(org: Organization): boolean {
|
||||
canEdit(org: Organization | undefined): boolean {
|
||||
if (this.isDefaultCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (org != null && org.id !== this.organizationId) {
|
||||
throw new Error(
|
||||
"Id of the organization provided does not match the org id of the collection.",
|
||||
@@ -71,7 +73,7 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
* Returns true if the user can delete a collection from the individual vault.
|
||||
* Does not include admin permissions - see {@link CollectionAdminView.canDelete}.
|
||||
*/
|
||||
canDelete(org: Organization): boolean {
|
||||
canDelete(org: Organization | undefined): boolean {
|
||||
if (org != null && org.id !== this.organizationId) {
|
||||
throw new Error(
|
||||
"Id of the organization provided does not match the org id of the collection.",
|
||||
@@ -81,7 +83,7 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
const canDeleteManagedCollections = !org?.limitCollectionDeletion || org.isAdmin;
|
||||
|
||||
// Only use individual permissions, not admin permissions
|
||||
return canDeleteManagedCollections && this.manage;
|
||||
return canDeleteManagedCollections && this.manage && !this.isDefaultCollection;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,4 +96,8 @@ export class CollectionView implements View, ITreeNodeObject {
|
||||
static fromJSON(obj: Jsonify<CollectionView>) {
|
||||
return Object.assign(new CollectionView(new Collection()), obj);
|
||||
}
|
||||
|
||||
get isDefaultCollection() {
|
||||
return this.type == CollectionTypes.DefaultUserCollection;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
|
||||
const orgIds = new Set(orgs.map((org) => org.id));
|
||||
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
|
||||
const hasManageCollections = collections.some(
|
||||
(c) => c.manage && orgIds.has(c.organizationId),
|
||||
(c) => c.manage && orgIds.has(c.organizationId!),
|
||||
);
|
||||
|
||||
// When the user has dismissed the nudge or spotlight, return the nudge status directly
|
||||
|
||||
@@ -46,7 +46,7 @@ export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
|
||||
const orgIds = new Set(orgs.map((org) => org.id));
|
||||
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
|
||||
const hasManageCollections = collections.some(
|
||||
(c) => c.manage && orgIds.has(c.organizationId),
|
||||
(c) => c.manage && orgIds.has(c.organizationId!),
|
||||
);
|
||||
|
||||
// When the user has dismissed the nudge or spotlight, return the nudge status directly
|
||||
|
||||
@@ -191,6 +191,9 @@ export function sortDefaultCollections(
|
||||
.sort((a, b) => {
|
||||
const aName = orgs.find((o) => o.id === a.organizationId)?.name ?? a.organizationId;
|
||||
const bName = orgs.find((o) => o.id === b.organizationId)?.name ?? b.organizationId;
|
||||
if (!aName || !bName) {
|
||||
throw new Error("Collection does not have an organizationId.");
|
||||
}
|
||||
return collator.compare(aName, bName);
|
||||
});
|
||||
return [
|
||||
|
||||
@@ -54,6 +54,7 @@ export enum FeatureFlag {
|
||||
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
|
||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
EndUserNotifications = "pm-10609-end-user-notifications",
|
||||
RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy",
|
||||
@@ -103,6 +104,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM19315EndUserActivationMvp]: FALSE,
|
||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM16117_SetInitialPasswordRefactor]: FALSE,
|
||||
|
||||
@@ -41,7 +41,6 @@ export abstract class CryptoFunctionService {
|
||||
algorithm: "sha1" | "sha256" | "sha512",
|
||||
): Promise<Uint8Array | string>;
|
||||
abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise<boolean>;
|
||||
abstract aesEncrypt(data: Uint8Array, iv: Uint8Array, key: Uint8Array): Promise<Uint8Array>;
|
||||
abstract aesDecryptFastParameters(
|
||||
data: string,
|
||||
iv: string,
|
||||
|
||||
@@ -7,20 +7,6 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr
|
||||
import { EncString } from "../models/enc-string";
|
||||
|
||||
export abstract class EncryptService {
|
||||
/**
|
||||
* @deprecated
|
||||
* Encrypts a string or Uint8Array to an EncString
|
||||
* @param plainValue - The value to encrypt
|
||||
* @param key - The key to encrypt the value with
|
||||
*/
|
||||
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
|
||||
/**
|
||||
* @deprecated
|
||||
* Encrypts a value to a Uint8Array
|
||||
* @param plainValue - The value to encrypt
|
||||
* @param key - The key to encrypt the value with
|
||||
*/
|
||||
abstract encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer>;
|
||||
/**
|
||||
* @deprecated
|
||||
* Decrypts an EncString to a string
|
||||
|
||||
@@ -153,6 +153,10 @@ export class EncString implements Encrypted {
|
||||
return EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType] === encPieces.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - This function is deprecated. Use EncryptService.decryptString instead.
|
||||
* @returns - The decrypted string, or `[error: cannot decrypt]` if decryption fails.
|
||||
*/
|
||||
async decrypt(
|
||||
orgId: string | null,
|
||||
key: SymmetricCryptoKey | null = null,
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import * as rxjs from "rxjs";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
import { BulkEncryptServiceImplementation } from "./bulk-encrypt.service.implementation";
|
||||
|
||||
describe("BulkEncryptServiceImplementation", () => {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
let sut: BulkEncryptServiceImplementation;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new BulkEncryptServiceImplementation(cryptoFunctionService, logService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("decryptItems", () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
const mockWorker = mock<Worker>();
|
||||
let globalWindow: any;
|
||||
|
||||
beforeEach(() => {
|
||||
globalWindow = global.window;
|
||||
|
||||
// Mock creating a worker.
|
||||
global.Worker = jest.fn().mockImplementation(() => mockWorker);
|
||||
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
|
||||
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
global.URL.canParse = jest.fn().mockReturnValue(true);
|
||||
|
||||
// Mock the workers returned response.
|
||||
const mockMessageEvent = {
|
||||
id: "mock-guid",
|
||||
data: ["decrypted1", "decrypted2"],
|
||||
};
|
||||
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
|
||||
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.window = globalWindow;
|
||||
});
|
||||
|
||||
it("throws error if key is null", async () => {
|
||||
const nullKey = null as unknown as SymmetricCryptoKey;
|
||||
await expect(sut.decryptItems([], nullKey)).rejects.toThrow("No encryption key provided.");
|
||||
});
|
||||
|
||||
it("returns an empty array when items is null", async () => {
|
||||
const result = await sut.decryptItems(null as any, key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when items is empty", async () => {
|
||||
const result = await sut.decryptItems([], key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("decrypts items sequentially when window is undefined", async () => {
|
||||
// Make global window undefined.
|
||||
delete (global as any).window;
|
||||
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith(
|
||||
"Window not available in BulkEncryptService, decrypting sequentially",
|
||||
);
|
||||
expect(result).toEqual(["item1", "item2"]);
|
||||
expect(mockItems[0].decrypt).toHaveBeenCalledWith(key);
|
||||
expect(mockItems[1].decrypt).toHaveBeenCalledWith(key);
|
||||
});
|
||||
|
||||
it("uses workers for decryption when window is available", async () => {
|
||||
const mockDecryptedItems = ["decrypted1", "decrypted2"];
|
||||
jest
|
||||
.spyOn<any, any>(sut, "getDecryptedItemsFromWorkers")
|
||||
.mockResolvedValue(mockDecryptedItems);
|
||||
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(sut["getDecryptedItemsFromWorkers"]).toHaveBeenCalledWith(mockItems, key);
|
||||
expect(result).toEqual(mockDecryptedItems);
|
||||
});
|
||||
|
||||
it("creates new worker when none exist", async () => {
|
||||
(sut as any).currentServerConfig = undefined;
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create worker if one exists", async () => {
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
(sut as any).workers = [mockWorker];
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(global.Worker).not.toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
it("updates internal currentServerConfig to new config", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
|
||||
sut.onServerConfigChange(newConfig);
|
||||
|
||||
expect((sut as any).currentServerConfig).toBe(newConfig);
|
||||
});
|
||||
|
||||
it("does send a SetConfigMessage to workers when there is a worker", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
const mockWorker = mock<Worker>();
|
||||
(sut as any).workers = [mockWorker];
|
||||
|
||||
sut.onServerConfigChange(newConfig);
|
||||
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(buildSetConfigMessage({ newConfig }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createMockDecryptable<T extends InitializerMetadata>(
|
||||
returnValue: any,
|
||||
): MockProxy<Decryptable<T>> {
|
||||
const mockDecryptable = mock<Decryptable<T>>();
|
||||
mockDecryptable.decrypt.mockResolvedValue(returnValue);
|
||||
return mockDecryptable;
|
||||
}
|
||||
@@ -1,38 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, fromEvent, filter, map, takeUntil, defaultIfEmpty, Subject } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
|
||||
import {
|
||||
DefaultFeatureFlagValue,
|
||||
FeatureFlag,
|
||||
getFeatureFlagValue,
|
||||
} from "../../../enums/feature-flag.enum";
|
||||
import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
|
||||
const workerTTL = 60000; // 1 minute
|
||||
const maxWorkers = 8;
|
||||
const minNumberOfItemsForMultithreading = 400;
|
||||
|
||||
/**
|
||||
* @deprecated Will be deleted in an immediate subsequent PR
|
||||
*/
|
||||
export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
private workers: Worker[] = [];
|
||||
private timeout: any;
|
||||
private currentServerConfig: ServerConfig | undefined = undefined;
|
||||
protected useSDKForDecryption: boolean = DefaultFeatureFlagValue[FeatureFlag.UseSDKForDecryption];
|
||||
|
||||
private clear$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected logService: LogService,
|
||||
@@ -54,139 +35,12 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof window === "undefined" || this.useSDKForDecryption) {
|
||||
this.logService.info("Window not available in BulkEncryptService, decrypting sequentially");
|
||||
const results = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
results.push(await items[i].decrypt(key));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const decryptedItems = await this.getDecryptedItemsFromWorkers(items, key);
|
||||
return decryptedItems;
|
||||
}
|
||||
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.currentServerConfig = newConfig;
|
||||
this.useSDKForDecryption = getFeatureFlagValue(newConfig, FeatureFlag.UseSDKForDecryption);
|
||||
this.updateWorkerServerConfigs(newConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends items to a set of web workers to decrypt them. This utilizes multiple workers to decrypt items
|
||||
* faster without interrupting other operations (e.g. updating UI).
|
||||
*/
|
||||
private async getDecryptedItemsFromWorkers<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<T[]> {
|
||||
if (items == null || items.length < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.clearTimeout();
|
||||
|
||||
const hardwareConcurrency = navigator.hardwareConcurrency || 1;
|
||||
let numberOfWorkers = Math.min(hardwareConcurrency, maxWorkers);
|
||||
if (items.length < minNumberOfItemsForMultithreading) {
|
||||
numberOfWorkers = 1;
|
||||
}
|
||||
|
||||
this.logService.info(
|
||||
`Starting decryption using multithreading with ${numberOfWorkers} workers for ${items.length} items`,
|
||||
);
|
||||
|
||||
if (this.workers.length == 0) {
|
||||
for (let i = 0; i < numberOfWorkers; i++) {
|
||||
this.workers.push(
|
||||
new Worker(
|
||||
new URL(
|
||||
/* webpackChunkName: 'encrypt-worker' */
|
||||
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (this.currentServerConfig != undefined) {
|
||||
this.updateWorkerServerConfigs(this.currentServerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
const itemsPerWorker = Math.floor(items.length / this.workers.length);
|
||||
const results = [];
|
||||
|
||||
for (const [i, worker] of this.workers.entries()) {
|
||||
const start = i * itemsPerWorker;
|
||||
const end = start + itemsPerWorker;
|
||||
const itemsForWorker = items.slice(start, end);
|
||||
|
||||
// push the remaining items to the last worker
|
||||
if (i == this.workers.length - 1) {
|
||||
itemsForWorker.push(...items.slice(end));
|
||||
}
|
||||
|
||||
const id = Utils.newGuid();
|
||||
const request = buildDecryptMessage({
|
||||
id,
|
||||
items: itemsForWorker,
|
||||
key: key,
|
||||
});
|
||||
|
||||
worker.postMessage(request);
|
||||
results.push(
|
||||
firstValueFrom(
|
||||
fromEvent(worker, "message").pipe(
|
||||
filter((response: MessageEvent) => response.data?.id === id),
|
||||
map((response) => JSON.parse(response.data.items)),
|
||||
map((items) =>
|
||||
items.map((jsonItem: Jsonify<T>) => {
|
||||
const initializer = getClassInitializer<T>(jsonItem.initializerKey);
|
||||
return initializer(jsonItem);
|
||||
}),
|
||||
),
|
||||
takeUntil(this.clear$),
|
||||
defaultIfEmpty([]),
|
||||
),
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
results.push(await items[i].decrypt(key));
|
||||
}
|
||||
|
||||
const decryptedItems = (await Promise.all(results)).flat();
|
||||
this.logService.info(
|
||||
`Finished decrypting ${decryptedItems.length} items using ${numberOfWorkers} workers`,
|
||||
);
|
||||
|
||||
this.restartTimeout();
|
||||
|
||||
return decryptedItems;
|
||||
return results;
|
||||
}
|
||||
|
||||
private updateWorkerServerConfigs(newConfig: ServerConfig) {
|
||||
this.workers.forEach((worker) => {
|
||||
const request = buildSetConfigMessage({ newConfig });
|
||||
worker.postMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.clear$.next();
|
||||
for (const worker of this.workers) {
|
||||
worker.terminate();
|
||||
}
|
||||
this.workers = [];
|
||||
this.clearTimeout();
|
||||
}
|
||||
|
||||
private restartTimeout() {
|
||||
this.clearTimeout();
|
||||
this.timeout = setTimeout(() => this.clear(), workerTTL);
|
||||
}
|
||||
|
||||
private clearTimeout() {
|
||||
if (this.timeout != null) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
}
|
||||
onServerConfigChange(newConfig: ServerConfig): void {}
|
||||
}
|
||||
|
||||
@@ -3,36 +3,20 @@
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
EncryptionType,
|
||||
encryptionTypeToString as encryptionTypeName,
|
||||
} from "@bitwarden/common/platform/enums";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { EncryptedObject } from "@bitwarden/common/platform/models/domain/encrypted-object";
|
||||
import {
|
||||
Aes256CbcHmacKey,
|
||||
Aes256CbcKey,
|
||||
SymmetricCryptoKey,
|
||||
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import {
|
||||
DefaultFeatureFlagValue,
|
||||
FeatureFlag,
|
||||
getFeatureFlagValue,
|
||||
} from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
export class EncryptServiceImplementation implements EncryptService {
|
||||
protected useSDKForDecryption: boolean = DefaultFeatureFlagValue[FeatureFlag.UseSDKForDecryption];
|
||||
private blockType0: boolean = DefaultFeatureFlagValue[FeatureFlag.PM17987_BlockType0];
|
||||
|
||||
constructor(
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected logService: LogService,
|
||||
@@ -41,27 +25,40 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
|
||||
// Proxy functions; Their implementation are temporary before moving at this level to the SDK
|
||||
async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString> {
|
||||
return this.encrypt(plainValue, key);
|
||||
if (plainValue == null) {
|
||||
this.logService.warning(
|
||||
"[EncryptService] WARNING: encryptString called with null value. Returning null, but this behavior is deprecated and will be removed.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
await SdkLoadService.Ready;
|
||||
return new EncString(PureCrypto.symmetric_encrypt_string(plainValue, key.toEncoded()));
|
||||
}
|
||||
|
||||
async encryptBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncString> {
|
||||
return this.encrypt(plainValue, key);
|
||||
await SdkLoadService.Ready;
|
||||
return new EncString(PureCrypto.symmetric_encrypt_bytes(plainValue, key.toEncoded()));
|
||||
}
|
||||
|
||||
async encryptFileData(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer> {
|
||||
return this.encryptToBytes(plainValue, key);
|
||||
await SdkLoadService.Ready;
|
||||
return new EncArrayBuffer(PureCrypto.symmetric_encrypt_filedata(plainValue, key.toEncoded()));
|
||||
}
|
||||
|
||||
async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise<string> {
|
||||
return this.decryptToUtf8(encString, key);
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.symmetric_decrypt_string(encString.encryptedString, key.toEncoded());
|
||||
}
|
||||
|
||||
async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise<Uint8Array> {
|
||||
return this.decryptToBytes(encString, key);
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.symmetric_decrypt_bytes(encString.encryptedString, key.toEncoded());
|
||||
}
|
||||
|
||||
async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<Uint8Array> {
|
||||
return this.decryptToBytes(encBuffer, key);
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.symmetric_decrypt_filedata(encBuffer.buffer, key.toEncoded());
|
||||
}
|
||||
|
||||
async wrapDecapsulationKey(
|
||||
@@ -76,7 +73,10 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(decapsulationKeyPkcs8, wrappingKey);
|
||||
await SdkLoadService.Ready;
|
||||
return new EncString(
|
||||
PureCrypto.wrap_decapsulation_key(decapsulationKeyPkcs8, wrappingKey.toEncoded()),
|
||||
);
|
||||
}
|
||||
|
||||
async wrapEncapsulationKey(
|
||||
@@ -91,7 +91,10 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(encapsulationKeySpki, wrappingKey);
|
||||
await SdkLoadService.Ready;
|
||||
return new EncString(
|
||||
PureCrypto.wrap_encapsulation_key(encapsulationKeySpki, wrappingKey.toEncoded()),
|
||||
);
|
||||
}
|
||||
|
||||
async wrapSymmetricKey(
|
||||
@@ -106,26 +109,61 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(keyToBeWrapped.toEncoded(), wrappingKey);
|
||||
await SdkLoadService.Ready;
|
||||
return new EncString(
|
||||
PureCrypto.wrap_symmetric_key(keyToBeWrapped.toEncoded(), wrappingKey.toEncoded()),
|
||||
);
|
||||
}
|
||||
|
||||
async unwrapDecapsulationKey(
|
||||
wrappedDecapsulationKey: EncString,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<Uint8Array> {
|
||||
return this.decryptBytes(wrappedDecapsulationKey, wrappingKey);
|
||||
if (wrappedDecapsulationKey == null) {
|
||||
throw new Error("No wrappedDecapsulationKey provided for unwrapping.");
|
||||
}
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for unwrapping.");
|
||||
}
|
||||
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.unwrap_decapsulation_key(
|
||||
wrappedDecapsulationKey.encryptedString,
|
||||
wrappingKey.toEncoded(),
|
||||
);
|
||||
}
|
||||
async unwrapEncapsulationKey(
|
||||
wrappedEncapsulationKey: EncString,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<Uint8Array> {
|
||||
return this.decryptBytes(wrappedEncapsulationKey, wrappingKey);
|
||||
if (wrappedEncapsulationKey == null) {
|
||||
throw new Error("No wrappedEncapsulationKey provided for unwrapping.");
|
||||
}
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for unwrapping.");
|
||||
}
|
||||
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.unwrap_encapsulation_key(
|
||||
wrappedEncapsulationKey.encryptedString,
|
||||
wrappingKey.toEncoded(),
|
||||
);
|
||||
}
|
||||
async unwrapSymmetricKey(
|
||||
keyToBeUnwrapped: EncString,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
return new SymmetricCryptoKey(await this.decryptBytes(keyToBeUnwrapped, wrappingKey));
|
||||
if (keyToBeUnwrapped == null) {
|
||||
throw new Error("No keyToBeUnwrapped provided for unwrapping.");
|
||||
}
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for unwrapping.");
|
||||
}
|
||||
|
||||
await SdkLoadService.Ready;
|
||||
return new SymmetricCryptoKey(
|
||||
PureCrypto.unwrap_symmetric_key(keyToBeUnwrapped.encryptedString, wrappingKey.toEncoded()),
|
||||
);
|
||||
}
|
||||
|
||||
async hash(value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512"): Promise<string> {
|
||||
@@ -134,261 +172,33 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
}
|
||||
|
||||
// Handle updating private properties to turn on/off feature flags.
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
const oldFlagValue = this.useSDKForDecryption;
|
||||
this.useSDKForDecryption = getFeatureFlagValue(newConfig, FeatureFlag.UseSDKForDecryption);
|
||||
this.logService.debug(
|
||||
"[EncryptService] Updated sdk decryption flag",
|
||||
oldFlagValue,
|
||||
this.useSDKForDecryption,
|
||||
);
|
||||
this.blockType0 = getFeatureFlagValue(newConfig, FeatureFlag.PM17987_BlockType0);
|
||||
}
|
||||
|
||||
async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
if (plainValue == null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (typeof plainValue === "string") {
|
||||
return this.encryptUint8Array(Utils.fromUtf8ToArray(plainValue), key);
|
||||
} else {
|
||||
return this.encryptUint8Array(plainValue, key);
|
||||
}
|
||||
}
|
||||
|
||||
private async encryptUint8Array(
|
||||
plainValue: Uint8Array,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
if (plainValue == null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const innerKey = key.inner();
|
||||
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const encObj = await this.aesEncrypt(plainValue, innerKey);
|
||||
const iv = Utils.fromBufferToB64(encObj.iv);
|
||||
const data = Utils.fromBufferToB64(encObj.data);
|
||||
const mac = Utils.fromBufferToB64(encObj.mac);
|
||||
return new EncString(innerKey.type, data, iv, mac);
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
const encObj = await this.aesEncryptLegacy(plainValue, innerKey);
|
||||
const iv = Utils.fromBufferToB64(encObj.iv);
|
||||
const data = Utils.fromBufferToB64(encObj.data);
|
||||
return new EncString(innerKey.type, data, iv);
|
||||
}
|
||||
}
|
||||
|
||||
async encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
const innerKey = key.inner();
|
||||
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const encValue = await this.aesEncrypt(plainValue, innerKey);
|
||||
const macLen = encValue.mac.length;
|
||||
const encBytes = new Uint8Array(
|
||||
1 + encValue.iv.byteLength + macLen + encValue.data.byteLength,
|
||||
);
|
||||
encBytes.set([innerKey.type]);
|
||||
encBytes.set(new Uint8Array(encValue.iv), 1);
|
||||
encBytes.set(new Uint8Array(encValue.mac), 1 + encValue.iv.byteLength);
|
||||
encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength + macLen);
|
||||
return new EncArrayBuffer(encBytes);
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
const encValue = await this.aesEncryptLegacy(plainValue, innerKey);
|
||||
const encBytes = new Uint8Array(1 + encValue.iv.byteLength + encValue.data.byteLength);
|
||||
encBytes.set([innerKey.type]);
|
||||
encBytes.set(new Uint8Array(encValue.iv), 1);
|
||||
encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength);
|
||||
return new EncArrayBuffer(encBytes);
|
||||
}
|
||||
}
|
||||
onServerConfigChange(newConfig: ServerConfig): void {}
|
||||
|
||||
async decryptToUtf8(
|
||||
encString: EncString,
|
||||
key: SymmetricCryptoKey,
|
||||
decryptContext: string = "no context",
|
||||
_decryptContext: string = "no context",
|
||||
): Promise<string> {
|
||||
if (this.useSDKForDecryption) {
|
||||
this.logService.debug("decrypting with SDK");
|
||||
if (encString == null || encString.encryptedString == null) {
|
||||
throw new Error("encString is null or undefined");
|
||||
}
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.symmetric_decrypt(encString.encryptedString, key.toEncoded());
|
||||
}
|
||||
this.logService.debug("decrypting with javascript");
|
||||
|
||||
if (key == null) {
|
||||
throw new Error("No key provided for decryption.");
|
||||
}
|
||||
|
||||
const innerKey = key.inner();
|
||||
if (encString.encryptionType !== innerKey.type) {
|
||||
this.logDecryptError(
|
||||
"Key encryption type does not match payload encryption type",
|
||||
innerKey.type,
|
||||
encString.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const fastParams = this.cryptoFunctionService.aesDecryptFastParameters(
|
||||
encString.data,
|
||||
encString.iv,
|
||||
encString.mac,
|
||||
key,
|
||||
);
|
||||
|
||||
const computedMac = await this.cryptoFunctionService.hmacFast(
|
||||
fastParams.macData,
|
||||
fastParams.macKey,
|
||||
"sha256",
|
||||
);
|
||||
const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac);
|
||||
if (!macsEqual) {
|
||||
this.logMacFailed(
|
||||
"decryptToUtf8 MAC comparison failed. Key or payload has changed.",
|
||||
innerKey.type,
|
||||
encString.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this.cryptoFunctionService.aesDecryptFast({
|
||||
mode: "cbc",
|
||||
parameters: fastParams,
|
||||
});
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
const fastParams = this.cryptoFunctionService.aesDecryptFastParameters(
|
||||
encString.data,
|
||||
encString.iv,
|
||||
undefined,
|
||||
key,
|
||||
);
|
||||
return await this.cryptoFunctionService.aesDecryptFast({
|
||||
mode: "cbc",
|
||||
parameters: fastParams,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported encryption type`);
|
||||
}
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.symmetric_decrypt(encString.encryptedString, key.toEncoded());
|
||||
}
|
||||
|
||||
async decryptToBytes(
|
||||
encThing: Encrypted,
|
||||
key: SymmetricCryptoKey,
|
||||
decryptContext: string = "no context",
|
||||
_decryptContext: string = "no context",
|
||||
): Promise<Uint8Array | null> {
|
||||
if (this.useSDKForDecryption) {
|
||||
this.logService.debug("[EncryptService] Decrypting bytes with SDK");
|
||||
if (
|
||||
encThing.encryptionType == null ||
|
||||
encThing.ivBytes == null ||
|
||||
encThing.dataBytes == null
|
||||
) {
|
||||
throw new Error("Cannot decrypt, missing type, IV, or data bytes.");
|
||||
}
|
||||
const buffer = EncArrayBuffer.fromParts(
|
||||
encThing.encryptionType,
|
||||
encThing.ivBytes,
|
||||
encThing.dataBytes,
|
||||
encThing.macBytes,
|
||||
).buffer;
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.toEncoded());
|
||||
}
|
||||
this.logService.debug("[EncryptService] Decrypting bytes with javascript");
|
||||
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (encThing == null) {
|
||||
throw new Error("Nothing provided for decryption.");
|
||||
}
|
||||
|
||||
const inner = key.inner();
|
||||
if (encThing.encryptionType !== inner.type) {
|
||||
this.logDecryptError(
|
||||
"Encryption key type mismatch",
|
||||
inner.type,
|
||||
encThing.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inner.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
if (encThing.macBytes == null) {
|
||||
this.logDecryptError("Mac missing", inner.type, encThing.encryptionType, decryptContext);
|
||||
return null;
|
||||
}
|
||||
|
||||
const macData = new Uint8Array(encThing.ivBytes.byteLength + encThing.dataBytes.byteLength);
|
||||
macData.set(new Uint8Array(encThing.ivBytes), 0);
|
||||
macData.set(new Uint8Array(encThing.dataBytes), encThing.ivBytes.byteLength);
|
||||
const computedMac = await this.cryptoFunctionService.hmac(
|
||||
macData,
|
||||
inner.authenticationKey,
|
||||
"sha256",
|
||||
);
|
||||
const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac);
|
||||
if (!macsMatch) {
|
||||
this.logMacFailed(
|
||||
"MAC comparison failed. Key or payload has changed.",
|
||||
inner.type,
|
||||
encThing.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.cryptoFunctionService.aesDecrypt(
|
||||
encThing.dataBytes,
|
||||
encThing.ivBytes,
|
||||
inner.encryptionKey,
|
||||
"cbc",
|
||||
);
|
||||
} else if (inner.type === EncryptionType.AesCbc256_B64) {
|
||||
return await this.cryptoFunctionService.aesDecrypt(
|
||||
encThing.dataBytes,
|
||||
encThing.ivBytes,
|
||||
inner.encryptionKey,
|
||||
"cbc",
|
||||
);
|
||||
if (encThing.encryptionType == null || encThing.ivBytes == null || encThing.dataBytes == null) {
|
||||
throw new Error("Cannot decrypt, missing type, IV, or data bytes.");
|
||||
}
|
||||
const buffer = EncArrayBuffer.fromParts(
|
||||
encThing.encryptionType,
|
||||
encThing.ivBytes,
|
||||
encThing.dataBytes,
|
||||
encThing.macBytes,
|
||||
).buffer;
|
||||
await SdkLoadService.Ready;
|
||||
return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.toEncoded());
|
||||
}
|
||||
|
||||
async encapsulateKeyUnsigned(
|
||||
@@ -398,14 +208,31 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
if (sharedKey == null) {
|
||||
throw new Error("No sharedKey provided for encapsulation");
|
||||
}
|
||||
return await this.rsaEncrypt(sharedKey.toEncoded(), encapsulationKey);
|
||||
if (encapsulationKey == null) {
|
||||
throw new Error("No encapsulationKey provided for encapsulation");
|
||||
}
|
||||
await SdkLoadService.Ready;
|
||||
return new EncString(
|
||||
PureCrypto.encapsulate_key_unsigned(sharedKey.toEncoded(), encapsulationKey),
|
||||
);
|
||||
}
|
||||
|
||||
async decapsulateKeyUnsigned(
|
||||
encryptedSharedKey: EncString,
|
||||
decapsulationKey: Uint8Array,
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
const keyBytes = await this.rsaDecrypt(encryptedSharedKey, decapsulationKey);
|
||||
if (encryptedSharedKey == null) {
|
||||
throw new Error("No encryptedSharedKey provided for decapsulation");
|
||||
}
|
||||
if (decapsulationKey == null) {
|
||||
throw new Error("No decapsulationKey provided for decapsulation");
|
||||
}
|
||||
|
||||
const keyBytes = PureCrypto.decapsulate_key_unsigned(
|
||||
encryptedSharedKey.encryptedString,
|
||||
decapsulationKey,
|
||||
);
|
||||
await SdkLoadService.Ready;
|
||||
return new SymmetricCryptoKey(keyBytes);
|
||||
}
|
||||
|
||||
@@ -428,51 +255,6 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
return results;
|
||||
}
|
||||
|
||||
private async aesEncrypt(data: Uint8Array, key: Aes256CbcHmacKey): Promise<EncryptedObject> {
|
||||
const obj = new EncryptedObject();
|
||||
obj.iv = await this.cryptoFunctionService.randomBytes(16);
|
||||
obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, key.encryptionKey);
|
||||
|
||||
const macData = new Uint8Array(obj.iv.byteLength + obj.data.byteLength);
|
||||
macData.set(new Uint8Array(obj.iv), 0);
|
||||
macData.set(new Uint8Array(obj.data), obj.iv.byteLength);
|
||||
obj.mac = await this.cryptoFunctionService.hmac(macData, key.authenticationKey, "sha256");
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Removed once AesCbc256_B64 support is removed
|
||||
*/
|
||||
private async aesEncryptLegacy(data: Uint8Array, key: Aes256CbcKey): Promise<EncryptedObject> {
|
||||
const obj = new EncryptedObject();
|
||||
obj.iv = await this.cryptoFunctionService.randomBytes(16);
|
||||
obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, key.encryptionKey);
|
||||
return obj;
|
||||
}
|
||||
|
||||
private logDecryptError(
|
||||
msg: string,
|
||||
keyEncType: EncryptionType,
|
||||
dataEncType: EncryptionType,
|
||||
decryptContext: string,
|
||||
) {
|
||||
this.logService.error(
|
||||
`[Encrypt service] ${msg} Key type ${encryptionTypeName(keyEncType)} Payload type ${encryptionTypeName(dataEncType)} Decrypt context: ${decryptContext}`,
|
||||
);
|
||||
}
|
||||
|
||||
private logMacFailed(
|
||||
msg: string,
|
||||
keyEncType: EncryptionType,
|
||||
dataEncType: EncryptionType,
|
||||
decryptContext: string,
|
||||
) {
|
||||
if (this.logMacFailures) {
|
||||
this.logDecryptError(msg, keyEncType, dataEncType, decryptContext);
|
||||
}
|
||||
}
|
||||
|
||||
async rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString> {
|
||||
if (data == null) {
|
||||
throw new Error("No data provided for encryption.");
|
||||
|
||||
@@ -3,20 +3,14 @@ import { mockReset, mock } from "jest-mock-extended";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import {
|
||||
Aes256CbcHmacKey,
|
||||
SymmetricCryptoKey,
|
||||
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
|
||||
@@ -26,24 +20,50 @@ describe("EncryptService", () => {
|
||||
|
||||
let encryptService: EncryptServiceImplementation;
|
||||
|
||||
const testEncBuffer = EncArrayBuffer.fromParts(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
new Uint8Array(16),
|
||||
new Uint8Array(32),
|
||||
new Uint8Array(32),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(cryptoFunctionService);
|
||||
mockReset(logService);
|
||||
|
||||
jest.spyOn(PureCrypto, "symmetric_decrypt_array_buffer").mockReturnValue(new Uint8Array(1));
|
||||
jest.spyOn(PureCrypto, "symmetric_decrypt").mockReturnValue("decrypted_string");
|
||||
|
||||
jest.spyOn(PureCrypto, "symmetric_decrypt_filedata").mockReturnValue(new Uint8Array(1));
|
||||
jest.spyOn(PureCrypto, "symmetric_encrypt_filedata").mockReturnValue(testEncBuffer.buffer);
|
||||
jest.spyOn(PureCrypto, "symmetric_decrypt_string").mockReturnValue("decrypted_string");
|
||||
jest.spyOn(PureCrypto, "symmetric_encrypt_string").mockReturnValue("encrypted_string");
|
||||
jest.spyOn(PureCrypto, "symmetric_decrypt_bytes").mockReturnValue(new Uint8Array(3));
|
||||
jest.spyOn(PureCrypto, "symmetric_encrypt_bytes").mockReturnValue("encrypted_bytes");
|
||||
|
||||
jest.spyOn(PureCrypto, "wrap_decapsulation_key").mockReturnValue("wrapped_decapsulation_key");
|
||||
jest.spyOn(PureCrypto, "wrap_encapsulation_key").mockReturnValue("wrapped_encapsulation_key");
|
||||
jest.spyOn(PureCrypto, "wrap_symmetric_key").mockReturnValue("wrapped_symmetric_key");
|
||||
jest.spyOn(PureCrypto, "unwrap_decapsulation_key").mockReturnValue(new Uint8Array(4));
|
||||
jest.spyOn(PureCrypto, "unwrap_encapsulation_key").mockReturnValue(new Uint8Array(5));
|
||||
jest.spyOn(PureCrypto, "unwrap_symmetric_key").mockReturnValue(new Uint8Array(64));
|
||||
|
||||
jest.spyOn(PureCrypto, "decapsulate_key_unsigned").mockReturnValue(new Uint8Array(64));
|
||||
jest.spyOn(PureCrypto, "encapsulate_key_unsigned").mockReturnValue("encapsulated_key_unsigned");
|
||||
(SdkLoadService as any).Ready = jest.fn().mockResolvedValue(true);
|
||||
|
||||
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
});
|
||||
|
||||
describe("wrapSymmetricKey", () => {
|
||||
it("roundtrip encrypts and decrypts a symmetric key", async () => {
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = await encryptService.wrapSymmetricKey(key, wrappingKey);
|
||||
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
|
||||
await encryptService.wrapSymmetricKey(key, wrappingKey);
|
||||
expect(PureCrypto.wrap_symmetric_key).toHaveBeenCalledWith(
|
||||
key.toEncoded(),
|
||||
wrappingKey.toEncoded(),
|
||||
);
|
||||
});
|
||||
it("fails if key toBeWrapped is null", async () => {
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
@@ -57,33 +77,17 @@ describe("EncryptService", () => {
|
||||
"No wrappingKey provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("fails if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(encryptService.wrapSymmetricKey(mock32Key, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapDecapsulationKey", () => {
|
||||
it("roundtrip encrypts and decrypts a decapsulation key", async () => {
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const decapsulationKey = makeStaticByteArray(10);
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = await encryptService.wrapDecapsulationKey(
|
||||
makeStaticByteArray(64),
|
||||
wrappingKey,
|
||||
await encryptService.wrapDecapsulationKey(decapsulationKey, wrappingKey);
|
||||
expect(PureCrypto.wrap_decapsulation_key).toHaveBeenCalledWith(
|
||||
decapsulationKey,
|
||||
wrappingKey.toEncoded(),
|
||||
);
|
||||
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
|
||||
});
|
||||
it("fails if decapsulation key is null", async () => {
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
@@ -97,33 +101,17 @@ describe("EncryptService", () => {
|
||||
"No wrappingKey provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("throws if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(
|
||||
encryptService.wrapDecapsulationKey(new Uint8Array(200), mock32Key),
|
||||
).rejects.toThrow("Type 0 encryption is not supported.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapEncapsulationKey", () => {
|
||||
it("roundtrip encrypts and decrypts an encapsulationKey key", async () => {
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const encapsulationKey = makeStaticByteArray(10);
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = await encryptService.wrapEncapsulationKey(
|
||||
makeStaticByteArray(64),
|
||||
wrappingKey,
|
||||
await encryptService.wrapEncapsulationKey(encapsulationKey, wrappingKey);
|
||||
expect(PureCrypto.wrap_encapsulation_key).toHaveBeenCalledWith(
|
||||
encapsulationKey,
|
||||
wrappingKey.toEncoded(),
|
||||
);
|
||||
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
|
||||
});
|
||||
it("fails if encapsulation key is null", async () => {
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
@@ -137,535 +125,152 @@ describe("EncryptService", () => {
|
||||
"No wrappingKey provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("throws if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(
|
||||
encryptService.wrapEncapsulationKey(new Uint8Array(200), mock32Key),
|
||||
).rejects.toThrow("Type 0 encryption is not supported.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("updates internal flag with default value when not present in config", () => {
|
||||
encryptService.onServerConfigChange(newConfig);
|
||||
|
||||
expect((encryptService as any).blockType0).toBe(
|
||||
DefaultFeatureFlagValue[FeatureFlag.PM17987_BlockType0],
|
||||
);
|
||||
});
|
||||
|
||||
test.each([true, false])("updates internal flag with value in config", (expectedValue) => {
|
||||
newConfig.featureStates = { [FeatureFlag.PM17987_BlockType0]: expectedValue };
|
||||
|
||||
encryptService.onServerConfigChange(newConfig);
|
||||
|
||||
expect((encryptService as any).blockType0).toBe(expectedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypt", () => {
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.encrypt(null, null)).rejects.toThrow(
|
||||
"No encryption key provided.",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(encryptService.encrypt(null!, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
await expect(encryptService.encrypt(null!, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
|
||||
const plainValue = "data";
|
||||
await expect(encryptService.encrypt(plainValue, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
await expect(encryptService.encrypt(plainValue, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null if no data is provided with valid key", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const actual = await encryptService.encrypt(null, key);
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it("creates an EncString for Aes256Cbc", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const plainValue = "data";
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(4, 100));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
const result = await encryptService.encrypt(plainValue, key);
|
||||
expect(cryptoFunctionService.aesEncrypt).toHaveBeenCalledWith(
|
||||
Utils.fromByteStringToArray(plainValue),
|
||||
makeStaticByteArray(16),
|
||||
makeStaticByteArray(32),
|
||||
);
|
||||
expect(cryptoFunctionService.hmac).not.toHaveBeenCalled();
|
||||
|
||||
expect(Utils.fromB64ToArray(result.data).length).toEqual(4);
|
||||
expect(Utils.fromB64ToArray(result.iv).length).toEqual(16);
|
||||
});
|
||||
|
||||
it("creates an EncString for Aes256Cbc_HmacSha256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const plainValue = "data";
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(4, 100));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
const result = await encryptService.encrypt(plainValue, key);
|
||||
expect(cryptoFunctionService.aesEncrypt).toHaveBeenCalledWith(
|
||||
Utils.fromByteStringToArray(plainValue),
|
||||
makeStaticByteArray(16),
|
||||
makeStaticByteArray(32),
|
||||
);
|
||||
|
||||
const macData = new Uint8Array(16 + 4);
|
||||
macData.set(makeStaticByteArray(16));
|
||||
macData.set(makeStaticByteArray(4, 100), 16);
|
||||
expect(cryptoFunctionService.hmac).toHaveBeenCalledWith(
|
||||
macData,
|
||||
makeStaticByteArray(32, 32),
|
||||
"sha256",
|
||||
);
|
||||
|
||||
expect(Utils.fromB64ToArray(result.data).length).toEqual(4);
|
||||
expect(Utils.fromB64ToArray(result.iv).length).toEqual(16);
|
||||
expect(Utils.fromB64ToArray(result.mac).length).toEqual(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptToBytes", () => {
|
||||
const plainValue = makeStaticByteArray(16, 1);
|
||||
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.encryptToBytes(plainValue, null)).rejects.toThrow(
|
||||
"No encryption key",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if type 0 key provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: makeStaticByteArray(32),
|
||||
});
|
||||
|
||||
await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
|
||||
await expect(encryptService.encryptToBytes(plainValue, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
});
|
||||
|
||||
it("encrypts data with provided Aes256Cbc key and returns correct encbuffer", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const iv = makeStaticByteArray(16, 80);
|
||||
const cipherText = makeStaticByteArray(20, 150);
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(iv as CsprngArray);
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(cipherText);
|
||||
|
||||
const actual = await encryptService.encryptToBytes(plainValue, key);
|
||||
const expectedBytes = new Uint8Array(1 + iv.byteLength + cipherText.byteLength);
|
||||
expectedBytes.set([EncryptionType.AesCbc256_B64]);
|
||||
expectedBytes.set(iv, 1);
|
||||
expectedBytes.set(cipherText, 1 + iv.byteLength);
|
||||
|
||||
expect(actual.buffer).toEqualBuffer(expectedBytes);
|
||||
});
|
||||
|
||||
it("encrypts data with provided Aes256Cbc_HmacSha256 key and returns correct encbuffer", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const iv = makeStaticByteArray(16, 80);
|
||||
const mac = makeStaticByteArray(32, 100);
|
||||
const cipherText = makeStaticByteArray(20, 150);
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(iv as CsprngArray);
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(cipherText);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(mac);
|
||||
|
||||
const actual = await encryptService.encryptToBytes(plainValue, key);
|
||||
const expectedBytes = new Uint8Array(
|
||||
1 + iv.byteLength + mac.byteLength + cipherText.byteLength,
|
||||
);
|
||||
expectedBytes.set([EncryptionType.AesCbc256_HmacSha256_B64]);
|
||||
expectedBytes.set(iv, 1);
|
||||
expectedBytes.set(mac, 1 + iv.byteLength);
|
||||
expectedBytes.set(cipherText, 1 + iv.byteLength + mac.byteLength);
|
||||
|
||||
expect(actual.buffer).toEqualBuffer(expectedBytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptToBytes", () => {
|
||||
const encType = EncryptionType.AesCbc256_HmacSha256_B64;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 100));
|
||||
const computedMac = new Uint8Array(1);
|
||||
const encBuffer = new EncArrayBuffer(makeStaticByteArray(60, encType));
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService.hmac.mockResolvedValue(computedMac);
|
||||
});
|
||||
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.decryptToBytes(encBuffer, null)).rejects.toThrow(
|
||||
"No encryption key",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if no encrypted value is provided", () => {
|
||||
return expect(encryptService.decryptToBytes(null, key)).rejects.toThrow(
|
||||
"Nothing provided for decryption",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls PureCrypto when useSDKForDecryption is true", async () => {
|
||||
(encryptService as any).useSDKForDecryption = true;
|
||||
const decryptedBytes = makeStaticByteArray(10, 200);
|
||||
Object.defineProperty(SdkLoadService, "Ready", {
|
||||
value: Promise.resolve(),
|
||||
configurable: true,
|
||||
});
|
||||
jest.spyOn(PureCrypto, "symmetric_decrypt_array_buffer").mockReturnValue(decryptedBytes);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
|
||||
expect(PureCrypto.symmetric_decrypt_array_buffer).toHaveBeenCalledWith(
|
||||
encBuffer.buffer,
|
||||
key.toEncoded(),
|
||||
);
|
||||
expect(actual).toEqualBuffer(decryptedBytes);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for Aes256CbcHmac", async () => {
|
||||
const decryptedBytes = makeStaticByteArray(10, 200);
|
||||
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1));
|
||||
cryptoFunctionService.compare.mockResolvedValue(true);
|
||||
cryptoFunctionService.aesDecrypt.mockResolvedValueOnce(decryptedBytes);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
|
||||
expect(cryptoFunctionService.aesDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(encBuffer.dataBytes),
|
||||
expect.toEqualBuffer(encBuffer.ivBytes),
|
||||
expect.toEqualBuffer(key.inner().encryptionKey),
|
||||
"cbc",
|
||||
);
|
||||
|
||||
expect(actual).toEqualBuffer(decryptedBytes);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for Aes256Cbc", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const encBuffer = new EncArrayBuffer(makeStaticByteArray(60, EncryptionType.AesCbc256_B64));
|
||||
const decryptedBytes = makeStaticByteArray(10, 200);
|
||||
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1));
|
||||
cryptoFunctionService.compare.mockResolvedValue(true);
|
||||
cryptoFunctionService.aesDecrypt.mockResolvedValueOnce(decryptedBytes);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
|
||||
expect(cryptoFunctionService.aesDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(encBuffer.dataBytes),
|
||||
expect.toEqualBuffer(encBuffer.ivBytes),
|
||||
expect.toEqualBuffer(key.inner().encryptionKey),
|
||||
"cbc",
|
||||
);
|
||||
|
||||
expect(actual).toEqualBuffer(decryptedBytes);
|
||||
});
|
||||
|
||||
it("compares macs using CryptoFunctionService", async () => {
|
||||
const expectedMacData = new Uint8Array(
|
||||
encBuffer.ivBytes.byteLength + encBuffer.dataBytes.byteLength,
|
||||
);
|
||||
expectedMacData.set(new Uint8Array(encBuffer.ivBytes));
|
||||
expectedMacData.set(new Uint8Array(encBuffer.dataBytes), encBuffer.ivBytes.byteLength);
|
||||
|
||||
await encryptService.decryptToBytes(encBuffer, key);
|
||||
|
||||
expect(cryptoFunctionService.hmac).toBeCalledWith(
|
||||
expect.toEqualBuffer(expectedMacData),
|
||||
(key.inner() as Aes256CbcHmacKey).authenticationKey,
|
||||
"sha256",
|
||||
);
|
||||
|
||||
expect(cryptoFunctionService.compare).toBeCalledWith(
|
||||
expect.toEqualBuffer(encBuffer.macBytes),
|
||||
expect.toEqualBuffer(computedMac),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null if macs don't match", async () => {
|
||||
cryptoFunctionService.compare.mockResolvedValue(false);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
expect(cryptoFunctionService.compare).toHaveBeenCalled();
|
||||
expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled();
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if mac could not be calculated", async () => {
|
||||
cryptoFunctionService.hmac.mockResolvedValue(null);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
expect(cryptoFunctionService.hmac).toHaveBeenCalled();
|
||||
expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled();
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if key is Aes256Cbc but encbuffer is Aes256Cbc_HmacSha256", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
cryptoFunctionService.compare.mockResolvedValue(true);
|
||||
|
||||
const actual = await encryptService.decryptToBytes(encBuffer, key);
|
||||
|
||||
expect(actual).toBeNull();
|
||||
expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null if key is Aes256Cbc_HmacSha256 but encbuffer is Aes256Cbc", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.compare.mockResolvedValue(true);
|
||||
const buffer = new EncArrayBuffer(makeStaticByteArray(200, EncryptionType.AesCbc256_B64));
|
||||
const actual = await encryptService.decryptToBytes(buffer, key);
|
||||
|
||||
expect(actual).toBeNull();
|
||||
expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptToUtf8", () => {
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.decryptToUtf8(null, null)).rejects.toThrow(
|
||||
"No key provided for decryption.",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls PureCrypto when useSDKForDecryption is true", async () => {
|
||||
(encryptService as any).useSDKForDecryption = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
Object.defineProperty(SdkLoadService, "Ready", {
|
||||
value: Promise.resolve(),
|
||||
configurable: true,
|
||||
});
|
||||
jest.spyOn(PureCrypto, "symmetric_decrypt").mockReturnValue("data");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
|
||||
expect(actual).toEqual("data");
|
||||
expect(PureCrypto.symmetric_decrypt).toHaveBeenCalledWith(
|
||||
encString.encryptedString,
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for AesCbc256_HmacSha256", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
cryptoFunctionService.aesDecryptFastParameters.mockReturnValue({
|
||||
macData: makeStaticByteArray(32, 0),
|
||||
macKey: makeStaticByteArray(32, 0),
|
||||
mac: makeStaticByteArray(32, 0),
|
||||
} as any);
|
||||
cryptoFunctionService.hmacFast.mockResolvedValue(makeStaticByteArray(32, 0));
|
||||
cryptoFunctionService.compareFast.mockResolvedValue(true);
|
||||
cryptoFunctionService.aesDecryptFast.mockResolvedValue("data");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
expect(actual).toEqual("data");
|
||||
expect(cryptoFunctionService.compareFast).toHaveBeenCalledWith(
|
||||
makeStaticByteArray(32, 0),
|
||||
makeStaticByteArray(32, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for AesCbc256", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
cryptoFunctionService.aesDecryptFastParameters.mockReturnValue({
|
||||
macData: makeStaticByteArray(32, 0),
|
||||
macKey: makeStaticByteArray(32, 0),
|
||||
mac: makeStaticByteArray(32, 0),
|
||||
} as any);
|
||||
cryptoFunctionService.hmacFast.mockResolvedValue(makeStaticByteArray(32, 0));
|
||||
cryptoFunctionService.compareFast.mockResolvedValue(true);
|
||||
cryptoFunctionService.aesDecryptFast.mockResolvedValue("data");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
expect(actual).toEqual("data");
|
||||
expect(cryptoFunctionService.compareFast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null if key is AesCbc256_HMAC but encstring is AesCbc256", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
expect(actual).toBeNull();
|
||||
expect(logService.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null if key is AesCbc256 but encstring is AesCbc256_HMAC", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
expect(actual).toBeNull();
|
||||
expect(logService.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null if macs don't match", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
cryptoFunctionService.aesDecryptFastParameters.mockReturnValue({
|
||||
macData: makeStaticByteArray(32, 0),
|
||||
macKey: makeStaticByteArray(32, 0),
|
||||
mac: makeStaticByteArray(32, 0),
|
||||
} as any);
|
||||
cryptoFunctionService.hmacFast.mockResolvedValue(makeStaticByteArray(32, 0));
|
||||
cryptoFunctionService.compareFast.mockResolvedValue(false);
|
||||
cryptoFunctionService.aesDecryptFast.mockResolvedValue("data");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptToUtf8", () => {
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.decryptToUtf8(null, null)).rejects.toThrow(
|
||||
"No key provided for decryption.",
|
||||
);
|
||||
});
|
||||
it("returns null if key is mac key but encstring has no mac", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
expect(actual).toBeNull();
|
||||
expect(logService.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptString", () => {
|
||||
it("is a proxy to encrypt", async () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const plainValue = "data";
|
||||
encryptService.encrypt = jest.fn();
|
||||
await encryptService.encryptString(plainValue, key);
|
||||
expect(encryptService.encrypt).toHaveBeenCalledWith(plainValue, key);
|
||||
const result = await encryptService.encryptString(plainValue, key);
|
||||
expect(result).toEqual(new EncString("encrypted_string"));
|
||||
expect(PureCrypto.symmetric_encrypt_string).toHaveBeenCalledWith(plainValue, key.toEncoded());
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptBytes", () => {
|
||||
it("is a proxy to encrypt", async () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const plainValue = makeStaticByteArray(16, 1);
|
||||
encryptService.encrypt = jest.fn();
|
||||
await encryptService.encryptBytes(plainValue, key);
|
||||
expect(encryptService.encrypt).toHaveBeenCalledWith(plainValue, key);
|
||||
const result = await encryptService.encryptBytes(plainValue, key);
|
||||
expect(result).toEqual(new EncString("encrypted_bytes"));
|
||||
expect(PureCrypto.symmetric_encrypt_bytes).toHaveBeenCalledWith(plainValue, key.toEncoded());
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptFileData", () => {
|
||||
it("is a proxy to encryptToBytes", async () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const plainValue = makeStaticByteArray(16, 1);
|
||||
encryptService.encryptToBytes = jest.fn();
|
||||
await encryptService.encryptFileData(plainValue, key);
|
||||
expect(encryptService.encryptToBytes).toHaveBeenCalledWith(plainValue, key);
|
||||
const result = await encryptService.encryptFileData(plainValue, key);
|
||||
expect(result).toEqual(testEncBuffer);
|
||||
expect(PureCrypto.symmetric_encrypt_filedata).toHaveBeenCalledWith(
|
||||
plainValue,
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptString", () => {
|
||||
it("is a proxy to decryptToUtf8", async () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
encryptService.decryptToUtf8 = jest.fn();
|
||||
await encryptService.decryptString(encString, key);
|
||||
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, key);
|
||||
const encString = new EncString("encrypted_string");
|
||||
const result = await encryptService.decryptString(encString, key);
|
||||
expect(result).toEqual("decrypted_string");
|
||||
expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith(
|
||||
encString.encryptedString,
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptBytes", () => {
|
||||
it("is a proxy to decryptToBytes", async () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
encryptService.decryptToBytes = jest.fn();
|
||||
await encryptService.decryptBytes(encString, key);
|
||||
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(encString, key);
|
||||
const encString = new EncString("encrypted_bytes");
|
||||
const result = await encryptService.decryptBytes(encString, key);
|
||||
expect(result).toEqual(new Uint8Array(3));
|
||||
expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith(
|
||||
encString.encryptedString,
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptFileData", () => {
|
||||
it("is a proxy to decrypt", async () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncArrayBuffer(makeStaticByteArray(60, EncryptionType.AesCbc256_B64));
|
||||
encryptService.decryptToBytes = jest.fn();
|
||||
await encryptService.decryptFileData(encString, key);
|
||||
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(encString, key);
|
||||
const encString = new EncArrayBuffer(testEncBuffer.buffer);
|
||||
const result = await encryptService.decryptFileData(encString, key);
|
||||
expect(result).toEqual(new Uint8Array(1));
|
||||
expect(PureCrypto.symmetric_decrypt_filedata).toHaveBeenCalledWith(
|
||||
encString.buffer,
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapDecapsulationKey", () => {
|
||||
it("is a proxy to decryptBytes", async () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
encryptService.decryptBytes = jest.fn();
|
||||
await encryptService.unwrapDecapsulationKey(encString, key);
|
||||
expect(encryptService.decryptBytes).toHaveBeenCalledWith(encString, key);
|
||||
const encString = new EncString("wrapped_decapsulation_key");
|
||||
const result = await encryptService.unwrapDecapsulationKey(encString, key);
|
||||
expect(result).toEqual(new Uint8Array(4));
|
||||
expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith(
|
||||
encString.encryptedString,
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
it("throws if wrappedDecapsulationKey is null", () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
return expect(encryptService.unwrapDecapsulationKey(null, key)).rejects.toThrow(
|
||||
"No wrappedDecapsulationKey provided for unwrapping.",
|
||||
);
|
||||
});
|
||||
it("throws if wrappingKey is null", () => {
|
||||
const encString = new EncString("wrapped_decapsulation_key");
|
||||
return expect(encryptService.unwrapDecapsulationKey(encString, null)).rejects.toThrow(
|
||||
"No wrappingKey provided for unwrapping.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapEncapsulationKey", () => {
|
||||
it("is a proxy to decryptBytes", async () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
encryptService.decryptBytes = jest.fn();
|
||||
await encryptService.unwrapEncapsulationKey(encString, key);
|
||||
expect(encryptService.decryptBytes).toHaveBeenCalledWith(encString, key);
|
||||
const encString = new EncString("wrapped_encapsulation_key");
|
||||
const result = await encryptService.unwrapEncapsulationKey(encString, key);
|
||||
expect(result).toEqual(new Uint8Array(5));
|
||||
expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith(
|
||||
encString.encryptedString,
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
it("throws if wrappedEncapsulationKey is null", () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
return expect(encryptService.unwrapEncapsulationKey(null, key)).rejects.toThrow(
|
||||
"No wrappedEncapsulationKey provided for unwrapping.",
|
||||
);
|
||||
});
|
||||
it("throws if wrappingKey is null", () => {
|
||||
const encString = new EncString("wrapped_encapsulation_key");
|
||||
return expect(encryptService.unwrapEncapsulationKey(encString, null)).rejects.toThrow(
|
||||
"No wrappingKey provided for unwrapping.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapSymmetricKey", () => {
|
||||
it("is a proxy to decryptBytes", async () => {
|
||||
it("is a proxy to PureCrypto", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
const jestFn = jest.fn();
|
||||
jestFn.mockResolvedValue(new Uint8Array(64));
|
||||
encryptService.decryptBytes = jestFn;
|
||||
await encryptService.unwrapSymmetricKey(encString, key);
|
||||
expect(encryptService.decryptBytes).toHaveBeenCalledWith(encString, key);
|
||||
const encString = new EncString("wrapped_symmetric_key");
|
||||
const result = await encryptService.unwrapSymmetricKey(encString, key);
|
||||
expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64)));
|
||||
expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith(
|
||||
encString.encryptedString,
|
||||
key.toEncoded(),
|
||||
);
|
||||
});
|
||||
it("throws if keyToBeUnwrapped is null", () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
return expect(encryptService.unwrapSymmetricKey(null, key)).rejects.toThrow(
|
||||
"No keyToBeUnwrapped provided for unwrapping.",
|
||||
);
|
||||
});
|
||||
it("throws if wrappingKey is null", () => {
|
||||
const encString = new EncString("wrapped_symmetric_key");
|
||||
return expect(encryptService.unwrapSymmetricKey(encString, null)).rejects.toThrow(
|
||||
"No wrappingKey provided for unwrapping.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -690,23 +295,13 @@ describe("EncryptService", () => {
|
||||
|
||||
it("throws if no public key is provided", () => {
|
||||
return expect(encryptService.encapsulateKeyUnsigned(testKey, null)).rejects.toThrow(
|
||||
"No public key",
|
||||
"No encapsulationKey provided for encapsulation",
|
||||
);
|
||||
});
|
||||
|
||||
it("encrypts data with provided key", async () => {
|
||||
cryptoFunctionService.rsaEncrypt.mockResolvedValue(encryptedData);
|
||||
|
||||
const actual = await encryptService.encapsulateKeyUnsigned(testKey, publicKey);
|
||||
|
||||
expect(cryptoFunctionService.rsaEncrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(testKey.toEncoded()),
|
||||
expect.toEqualBuffer(publicKey),
|
||||
"sha1",
|
||||
);
|
||||
|
||||
expect(actual).toEqual(encString);
|
||||
expect(actual.dataBytes).toEqualBuffer(encryptedData);
|
||||
expect(actual).toEqual(new EncString("encapsulated_key_unsigned"));
|
||||
});
|
||||
|
||||
it("throws if no data was provided", () => {
|
||||
@@ -719,39 +314,19 @@ describe("EncryptService", () => {
|
||||
describe("decapsulateKeyUnsigned", () => {
|
||||
it("throws if no data is provided", () => {
|
||||
return expect(encryptService.decapsulateKeyUnsigned(null, privateKey)).rejects.toThrow(
|
||||
"No data",
|
||||
"No encryptedSharedKey provided for decapsulation",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if no private key is provided", () => {
|
||||
return expect(encryptService.decapsulateKeyUnsigned(encString, null)).rejects.toThrow(
|
||||
"No private key",
|
||||
"No decapsulationKey provided for decapsulation",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([EncryptionType.AesCbc256_B64, EncryptionType.AesCbc256_HmacSha256_B64])(
|
||||
"throws if encryption type is %s",
|
||||
async (encType) => {
|
||||
encString.encryptionType = encType;
|
||||
|
||||
await expect(
|
||||
encryptService.decapsulateKeyUnsigned(encString, privateKey),
|
||||
).rejects.toThrow("Invalid encryption type");
|
||||
},
|
||||
);
|
||||
|
||||
it("decrypts data with provided key", async () => {
|
||||
cryptoFunctionService.rsaDecrypt.mockResolvedValue(data);
|
||||
|
||||
const actual = await encryptService.decapsulateKeyUnsigned(makeEncString(data), privateKey);
|
||||
|
||||
expect(cryptoFunctionService.rsaDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(data),
|
||||
expect.toEqualBuffer(privateKey),
|
||||
"sha1",
|
||||
);
|
||||
|
||||
expect(actual.toEncoded()).toEqualBuffer(data);
|
||||
expect(actual.toEncoded()).toEqualBuffer(new Uint8Array(64));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { BulkEncryptService } from "../abstractions/bulk-encrypt.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
import { FallbackBulkEncryptService } from "./fallback-bulk-encrypt.service";
|
||||
|
||||
describe("FallbackBulkEncryptService", () => {
|
||||
const encryptService = mock<EncryptService>();
|
||||
const featureFlagEncryptService = mock<BulkEncryptService>();
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
|
||||
let sut: FallbackBulkEncryptService;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new FallbackBulkEncryptService(encryptService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("decryptItems", () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const mockItems = [{ id: "guid", name: "encryptedValue" }] as any[];
|
||||
const mockDecryptedItems = [{ id: "guid", name: "decryptedValue" }] as any[];
|
||||
|
||||
it("calls decryptItems on featureFlagEncryptService when it is set", async () => {
|
||||
featureFlagEncryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(featureFlagEncryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
|
||||
expect(encryptService.decryptItems).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockDecryptedItems);
|
||||
});
|
||||
|
||||
it("calls decryptItems on encryptService when featureFlagEncryptService is not set", async () => {
|
||||
encryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(encryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
|
||||
expect(result).toEqual(mockDecryptedItems);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFeatureFlagEncryptService", () => {
|
||||
it("sets the featureFlagEncryptService property", async () => {
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
|
||||
});
|
||||
|
||||
it("does not call onServerConfigChange when currentServerConfig is undefined", async () => {
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
expect(featureFlagEncryptService.onServerConfigChange).not.toHaveBeenCalled();
|
||||
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
|
||||
});
|
||||
|
||||
it("calls onServerConfigChange with currentServerConfig when it is defined", async () => {
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
|
||||
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
it("updates internal currentServerConfig to new config", async () => {
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect((sut as any).currentServerConfig).toBe(serverConfig);
|
||||
});
|
||||
|
||||
it("calls onServerConfigChange on featureFlagEncryptService when it is set", async () => {
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
|
||||
expect(encryptService.onServerConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onServerConfigChange on encryptService when featureFlagEncryptService is not set", () => {
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(encryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { ServerConfig } from "../../../platform/abstractions/config/server-confi
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
/**
|
||||
* @deprecated For the feature flag from PM-4154, remove once feature is rolled out
|
||||
* @deprecated Will be deleted in an immediate subsequent PR
|
||||
*/
|
||||
export class FallbackBulkEncryptService implements BulkEncryptService {
|
||||
private featureFlagEncryptService: BulkEncryptService;
|
||||
@@ -25,22 +25,10 @@ export class FallbackBulkEncryptService implements BulkEncryptService {
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<T[]> {
|
||||
if (this.featureFlagEncryptService != null) {
|
||||
return await this.featureFlagEncryptService.decryptItems(items, key);
|
||||
} else {
|
||||
return await this.encryptService.decryptItems(items, key);
|
||||
}
|
||||
return await this.encryptService.decryptItems(items, key);
|
||||
}
|
||||
|
||||
async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) {
|
||||
if (this.currentServerConfig !== undefined) {
|
||||
featureFlagEncryptService.onServerConfigChange(this.currentServerConfig);
|
||||
}
|
||||
this.featureFlagEncryptService = featureFlagEncryptService;
|
||||
}
|
||||
async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) {}
|
||||
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.currentServerConfig = newConfig;
|
||||
(this.featureFlagEncryptService ?? this.encryptService).onServerConfigChange(newConfig);
|
||||
}
|
||||
onServerConfigChange(newConfig: ServerConfig): void {}
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import * as rxjs from "rxjs";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
import { MultithreadEncryptServiceImplementation } from "./multithread-encrypt.service.implementation";
|
||||
|
||||
describe("MultithreadEncryptServiceImplementation", () => {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const logService = mock<LogService>();
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
|
||||
let sut: MultithreadEncryptServiceImplementation;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new MultithreadEncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("decryptItems", () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const mockWorker = mock<Worker>();
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock creating a worker.
|
||||
global.Worker = jest.fn().mockImplementation(() => mockWorker);
|
||||
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
|
||||
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
global.URL.canParse = jest.fn().mockReturnValue(true);
|
||||
|
||||
// Mock the workers returned response.
|
||||
const mockMessageEvent = {
|
||||
id: "mock-guid",
|
||||
data: ["decrypted1", "decrypted2"],
|
||||
};
|
||||
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
|
||||
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
|
||||
});
|
||||
|
||||
it("returns empty array if items is null", async () => {
|
||||
const items = null as unknown as Decryptable<any>[];
|
||||
const result = await sut.decryptItems(items, key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array if items is empty", async () => {
|
||||
const result = await sut.decryptItems([], key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates worker if none exists", async () => {
|
||||
// Make sure currentServerConfig is undefined so a SetConfigMessage is not sent.
|
||||
(sut as any).currentServerConfig = undefined;
|
||||
|
||||
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
|
||||
// Populate currentServerConfig so a SetConfigMessage is sent.
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
|
||||
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create worker if one exists", async () => {
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
(sut as any).worker = mockWorker;
|
||||
|
||||
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
|
||||
|
||||
expect(global.Worker).not.toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
it("updates internal currentServerConfig to new config and calls super", () => {
|
||||
const superSpy = jest.spyOn(EncryptServiceImplementation.prototype, "onServerConfigChange");
|
||||
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(superSpy).toHaveBeenCalledWith(serverConfig);
|
||||
expect((sut as any).currentServerConfig).toBe(serverConfig);
|
||||
});
|
||||
|
||||
it("sends config update to worker if worker exists", () => {
|
||||
const mockWorker = mock<Worker>();
|
||||
(sut as any).worker = mockWorker;
|
||||
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,16 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { defaultIfEmpty, filter, firstValueFrom, fromEvent, map, Subject, takeUntil } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
|
||||
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
|
||||
const workerTTL = 3 * 60000; // 3 minutes
|
||||
|
||||
/**
|
||||
* @deprecated Replaced by BulkEncryptionService (PM-4154)
|
||||
* @deprecated Will be deleted in an immediate subsequent PR
|
||||
*/
|
||||
export class MultithreadEncryptServiceImplementation extends EncryptServiceImplementation {
|
||||
private worker: Worker;
|
||||
private timeout: any;
|
||||
private currentServerConfig: ServerConfig | undefined = undefined;
|
||||
|
||||
private clear$ = new Subject<void>();
|
||||
protected useSDKForDecryption: boolean = true;
|
||||
|
||||
/**
|
||||
* Sends items to a web worker to decrypt them.
|
||||
@@ -35,84 +20,8 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<T[]> {
|
||||
if (items == null || items.length < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.useSDKForDecryption) {
|
||||
return await super.decryptItems(items, key);
|
||||
}
|
||||
|
||||
this.logService.info("Starting decryption using multithreading");
|
||||
|
||||
if (this.worker == null) {
|
||||
this.worker = new Worker(
|
||||
new URL(
|
||||
/* webpackChunkName: 'encrypt-worker' */
|
||||
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
if (this.currentServerConfig !== undefined) {
|
||||
this.updateWorkerServerConfig(this.currentServerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
this.restartTimeout();
|
||||
|
||||
const id = Utils.newGuid();
|
||||
const request = buildDecryptMessage({
|
||||
id,
|
||||
items: items,
|
||||
key: key,
|
||||
});
|
||||
|
||||
this.worker.postMessage(request);
|
||||
|
||||
return await firstValueFrom(
|
||||
fromEvent(this.worker, "message").pipe(
|
||||
filter((response: MessageEvent) => response.data?.id === id),
|
||||
map((response) => JSON.parse(response.data.items)),
|
||||
map((items) =>
|
||||
items.map((jsonItem: Jsonify<T>) => {
|
||||
const initializer = getClassInitializer<T>(jsonItem.initializerKey);
|
||||
return initializer(jsonItem);
|
||||
}),
|
||||
),
|
||||
takeUntil(this.clear$),
|
||||
defaultIfEmpty([]),
|
||||
),
|
||||
);
|
||||
return await super.decryptItems(items, key);
|
||||
}
|
||||
|
||||
override onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.currentServerConfig = newConfig;
|
||||
super.onServerConfigChange(newConfig);
|
||||
this.updateWorkerServerConfig(newConfig);
|
||||
}
|
||||
|
||||
private updateWorkerServerConfig(newConfig: ServerConfig) {
|
||||
if (this.worker != null) {
|
||||
const request = buildSetConfigMessage({ newConfig });
|
||||
this.worker.postMessage(request);
|
||||
}
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.clear$.next();
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.clearTimeout();
|
||||
}
|
||||
|
||||
private restartTimeout() {
|
||||
this.clearTimeout();
|
||||
this.timeout = setTimeout(() => this.clear(), workerTTL);
|
||||
}
|
||||
|
||||
private clearTimeout() {
|
||||
if (this.timeout != null) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
}
|
||||
override onServerConfigChange(newConfig: ServerConfig): void {}
|
||||
}
|
||||
|
||||
@@ -233,48 +233,6 @@ describe("WebCrypto Function Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("aesEncrypt CBC mode", () => {
|
||||
it("should successfully encrypt data", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const iv = makeStaticByteArray(16);
|
||||
const key = makeStaticByteArray(32);
|
||||
const data = Utils.fromUtf8ToArray("EncryptMe!");
|
||||
const encValue = await cryptoFunctionService.aesEncrypt(data, iv, key);
|
||||
expect(Utils.fromBufferToB64(encValue)).toBe("ByUF8vhyX4ddU9gcooznwA==");
|
||||
});
|
||||
|
||||
it("should successfully encrypt and then decrypt data fast", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const iv = makeStaticByteArray(16);
|
||||
const key = makeStaticByteArray(32);
|
||||
const value = "EncryptMe!";
|
||||
const data = Utils.fromUtf8ToArray(value);
|
||||
const encValue = await cryptoFunctionService.aesEncrypt(data, iv, key);
|
||||
const encData = Utils.fromBufferToB64(encValue);
|
||||
const b64Iv = Utils.fromBufferToB64(iv);
|
||||
const symKey = new SymmetricCryptoKey(key);
|
||||
const parameters = cryptoFunctionService.aesDecryptFastParameters(
|
||||
encData,
|
||||
b64Iv,
|
||||
null,
|
||||
symKey,
|
||||
);
|
||||
const decValue = await cryptoFunctionService.aesDecryptFast({ mode: "cbc", parameters });
|
||||
expect(decValue).toBe(value);
|
||||
});
|
||||
|
||||
it("should successfully encrypt and then decrypt data", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
const iv = makeStaticByteArray(16);
|
||||
const key = makeStaticByteArray(32);
|
||||
const value = "EncryptMe!";
|
||||
const data = Utils.fromUtf8ToArray(value);
|
||||
const encValue = new Uint8Array(await cryptoFunctionService.aesEncrypt(data, iv, key));
|
||||
const decValue = await cryptoFunctionService.aesDecrypt(encValue, iv, key, "cbc");
|
||||
expect(Utils.fromBufferToUtf8(decValue)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aesDecryptFast CBC mode", () => {
|
||||
it("should successfully decrypt data", async () => {
|
||||
const cryptoFunctionService = getWebCryptoFunctionService();
|
||||
|
||||
@@ -204,14 +204,6 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
return equals;
|
||||
}
|
||||
|
||||
async aesEncrypt(data: Uint8Array, iv: Uint8Array, key: Uint8Array): Promise<Uint8Array> {
|
||||
const impKey = await this.subtle.importKey("raw", key, { name: "AES-CBC" } as any, false, [
|
||||
"encrypt",
|
||||
]);
|
||||
const buffer = await this.subtle.encrypt({ name: "AES-CBC", iv: iv }, impKey, data);
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
aesDecryptFastParameters(
|
||||
data: string,
|
||||
iv: string,
|
||||
|
||||
@@ -228,6 +228,7 @@ export class DefaultSdkService implements SdkService {
|
||||
},
|
||||
privateKey,
|
||||
signingKey: undefined,
|
||||
securityState: undefined,
|
||||
});
|
||||
|
||||
// We initialize the org crypto even if the org_keys are
|
||||
|
||||
@@ -89,10 +89,13 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
}
|
||||
// Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey
|
||||
send.key = await this.encryptService.encryptBytes(model.key, userKey);
|
||||
// FIXME: model.name can be null. encryptString should not be called with null values.
|
||||
send.name = await this.encryptService.encryptString(model.name, model.cryptoKey);
|
||||
// FIXME: model.notes can be null. encryptString should not be called with null values.
|
||||
send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey);
|
||||
if (send.type === SendType.Text) {
|
||||
send.text = new SendText();
|
||||
// FIXME: model.text.text can be null. encryptString should not be called with null values.
|
||||
send.text.text = await this.encryptService.encryptString(model.text.text, model.cryptoKey);
|
||||
send.text.hidden = model.text.hidden;
|
||||
} else if (send.type === SendType.File) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherListView } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserId, OrganizationId } from "../../types/guid";
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
import { AttachmentView } from "../models/view/attachment.view";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
@@ -9,6 +10,28 @@ import { CipherView } from "../models/view/cipher.view";
|
||||
* Service responsible for encrypting and decrypting ciphers.
|
||||
*/
|
||||
export abstract class CipherEncryptionService {
|
||||
/**
|
||||
* Encrypts a cipher using the SDK for the given userId.
|
||||
* @param model The cipher view to encrypt
|
||||
* @param userId The user ID to initialize the SDK client with
|
||||
*
|
||||
* @returns A promise that resolves to the encryption context, or undefined if encryption fails
|
||||
*/
|
||||
abstract encrypt(model: CipherView, userId: UserId): Promise<EncryptionContext | undefined>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param model The cipher view to move to the organization
|
||||
* @param organizationId The ID of the organization to move the cipher to
|
||||
* @param userId The user ID to initialize the SDK client with
|
||||
*/
|
||||
abstract moveToOrganization(
|
||||
model: CipherView,
|
||||
organizationId: OrganizationId,
|
||||
userId: UserId,
|
||||
): Promise<EncryptionContext | undefined>;
|
||||
|
||||
/**
|
||||
* Decrypts a cipher using the SDK for the given userId.
|
||||
*
|
||||
|
||||
@@ -120,11 +120,21 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
orgAdmin?: boolean,
|
||||
isNotClone?: boolean,
|
||||
): Promise<Cipher>;
|
||||
|
||||
/**
|
||||
* Move a cipher to an organization by re-encrypting its keys with the organization's key.
|
||||
* @param cipher The cipher to move
|
||||
* @param organizationId The Id of the organization to move the cipher to
|
||||
* @param collectionIds The collection Ids to assign the cipher to in the organization
|
||||
* @param userId The Id of the user performing the operation
|
||||
* @param originalCipher Optional original cipher that will be used to compare/update password history
|
||||
*/
|
||||
abstract shareWithServer(
|
||||
cipher: CipherView,
|
||||
organizationId: string,
|
||||
collectionIds: string[],
|
||||
userId: UserId,
|
||||
originalCipher?: Cipher,
|
||||
): Promise<Cipher>;
|
||||
abstract shareManyWithServer(
|
||||
ciphers: CipherView[],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CipherPermissions as SdkCipherPermissions } from "@bitwarden/sdk-intern
|
||||
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class CipherPermissionsApi extends BaseResponse {
|
||||
export class CipherPermissionsApi extends BaseResponse implements SdkCipherPermissions {
|
||||
delete: boolean = false;
|
||||
restore: boolean = false;
|
||||
|
||||
@@ -35,4 +35,11 @@ export class CipherPermissionsApi extends BaseResponse {
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the CipherPermissionsApi to an SdkCipherPermissions
|
||||
*/
|
||||
toSdkCipherPermissions(): SdkCipherPermissions {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,45 @@
|
||||
import {
|
||||
LocalDataView as SdkLocalDataView,
|
||||
LocalData as SdkLocalData,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
export type LocalData = {
|
||||
lastUsedDate?: number;
|
||||
lastLaunched?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert the SdkLocalDataView to LocalData
|
||||
* @param localData
|
||||
*/
|
||||
export function fromSdkLocalData(
|
||||
localData: SdkLocalDataView | SdkLocalData | undefined,
|
||||
): LocalData | undefined {
|
||||
if (localData == null) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
lastUsedDate: localData.lastUsedDate ? new Date(localData.lastUsedDate).getTime() : undefined,
|
||||
lastLaunched: localData.lastLaunched ? new Date(localData.lastLaunched).getTime() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the LocalData to SdkLocalData
|
||||
* @param localData
|
||||
*/
|
||||
export function toSdkLocalData(
|
||||
localData: LocalData | undefined,
|
||||
): (SdkLocalDataView & SdkLocalData) | undefined {
|
||||
if (localData == null) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
lastUsedDate: localData.lastUsedDate
|
||||
? new Date(localData.lastUsedDate).toISOString()
|
||||
: undefined,
|
||||
lastLaunched: localData.lastLaunched
|
||||
? new Date(localData.lastLaunched).toISOString()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("Attachment", () => {
|
||||
sizeName: "1.1 KB",
|
||||
fileName: "fileName",
|
||||
key: expect.any(SymmetricCryptoKey),
|
||||
encryptedKey: attachment.key,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export class Attachment extends Domain {
|
||||
|
||||
if (this.key != null) {
|
||||
view.key = await this.decryptAttachmentKey(orgId, encKey);
|
||||
view.encryptedKey = this.key; // Keep the encrypted key for the view
|
||||
}
|
||||
|
||||
return view;
|
||||
@@ -131,4 +132,24 @@ export class Attachment extends Domain {
|
||||
key: this.key?.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an SDK Attachment object to an Attachment
|
||||
* @param obj - The SDK attachment object
|
||||
*/
|
||||
static fromSdkAttachment(obj: SdkAttachment): Attachment | undefined {
|
||||
if (!obj) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const attachment = new Attachment();
|
||||
attachment.id = obj.id;
|
||||
attachment.url = obj.url;
|
||||
attachment.size = obj.size;
|
||||
attachment.sizeName = obj.sizeName;
|
||||
attachment.fileName = EncString.fromJSON(obj.fileName);
|
||||
attachment.key = EncString.fromJSON(obj.key);
|
||||
|
||||
return attachment;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,4 +103,24 @@ export class Card extends Domain {
|
||||
code: this.code?.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an SDK Card object to a Card
|
||||
* @param obj - The SDK Card object
|
||||
*/
|
||||
static fromSdkCard(obj: SdkCard): Card | undefined {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const card = new Card();
|
||||
card.cardholderName = EncString.fromJSON(obj.cardholderName);
|
||||
card.brand = EncString.fromJSON(obj.brand);
|
||||
card.number = EncString.fromJSON(obj.number);
|
||||
card.expMonth = EncString.fromJSON(obj.expMonth);
|
||||
card.expYear = EncString.fromJSON(obj.expYear);
|
||||
card.code = EncString.fromJSON(obj.code);
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UriMatchType,
|
||||
CipherRepromptType as SdkCipherRepromptType,
|
||||
LoginLinkedIdType,
|
||||
Cipher as SdkCipher,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils";
|
||||
@@ -206,7 +207,7 @@ describe("Cipher DTO", () => {
|
||||
it("Convert", () => {
|
||||
const cipher = new Cipher(cipherData);
|
||||
|
||||
expect(cipher).toEqual({
|
||||
expect(cipher).toMatchObject({
|
||||
initializerKey: InitializerKey.Cipher,
|
||||
id: "id",
|
||||
organizationId: "orgId",
|
||||
@@ -339,9 +340,9 @@ describe("Cipher DTO", () => {
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
login: loginView,
|
||||
attachments: null,
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
attachments: [],
|
||||
fields: [],
|
||||
passwordHistory: [],
|
||||
collectionIds: undefined,
|
||||
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
creationDate: new Date("2022-01-01T12:00:00.000Z"),
|
||||
@@ -462,9 +463,9 @@ describe("Cipher DTO", () => {
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
secureNote: { type: 0 },
|
||||
attachments: null,
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
attachments: [],
|
||||
fields: [],
|
||||
passwordHistory: [],
|
||||
collectionIds: undefined,
|
||||
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
creationDate: new Date("2022-01-01T12:00:00.000Z"),
|
||||
@@ -603,9 +604,9 @@ describe("Cipher DTO", () => {
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
card: cardView,
|
||||
attachments: null,
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
attachments: [],
|
||||
fields: [],
|
||||
passwordHistory: [],
|
||||
collectionIds: undefined,
|
||||
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
creationDate: new Date("2022-01-01T12:00:00.000Z"),
|
||||
@@ -768,9 +769,9 @@ describe("Cipher DTO", () => {
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
identity: identityView,
|
||||
attachments: null,
|
||||
fields: null,
|
||||
passwordHistory: null,
|
||||
attachments: [],
|
||||
fields: [],
|
||||
passwordHistory: [],
|
||||
collectionIds: undefined,
|
||||
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
creationDate: new Date("2022-01-01T12:00:00.000Z"),
|
||||
@@ -1001,6 +1002,167 @@ describe("Cipher DTO", () => {
|
||||
revisionDate: "2022-01-31T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("should map from SDK Cipher", () => {
|
||||
jest.restoreAllMocks();
|
||||
const sdkCipher: SdkCipher = {
|
||||
id: "id",
|
||||
organizationId: "orgId",
|
||||
folderId: "folderId",
|
||||
collectionIds: [],
|
||||
key: "EncryptedString",
|
||||
name: "EncryptedString",
|
||||
notes: "EncryptedString",
|
||||
type: SdkCipherType.Login,
|
||||
login: {
|
||||
username: "EncryptedString",
|
||||
password: "EncryptedString",
|
||||
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
|
||||
uris: [
|
||||
{
|
||||
uri: "EncryptedString",
|
||||
uriChecksum: "EncryptedString",
|
||||
match: UriMatchType.Domain,
|
||||
},
|
||||
],
|
||||
totp: "EncryptedString",
|
||||
autofillOnPageLoad: false,
|
||||
fido2Credentials: undefined,
|
||||
},
|
||||
identity: undefined,
|
||||
card: undefined,
|
||||
secureNote: undefined,
|
||||
sshKey: undefined,
|
||||
favorite: false,
|
||||
reprompt: SdkCipherRepromptType.None,
|
||||
organizationUseTotp: true,
|
||||
edit: true,
|
||||
permissions: new CipherPermissionsApi(),
|
||||
viewPassword: true,
|
||||
localData: {
|
||||
lastUsedDate: "2025-04-15T12:00:00.000Z",
|
||||
lastLaunched: "2025-04-15T12: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.Linked,
|
||||
linkedId: LoginLinkedIdType.Username,
|
||||
},
|
||||
{
|
||||
name: "EncryptedString",
|
||||
value: "EncryptedString",
|
||||
type: FieldType.Linked,
|
||||
linkedId: LoginLinkedIdType.Password,
|
||||
},
|
||||
],
|
||||
passwordHistory: [
|
||||
{
|
||||
password: "EncryptedString",
|
||||
lastUsedDate: "2022-01-31T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
creationDate: "2022-01-01T12:00:00.000Z",
|
||||
deletedDate: undefined,
|
||||
revisionDate: "2022-01-31T12:00:00.000Z",
|
||||
};
|
||||
|
||||
const lastUsedDate = new Date("2025-04-15T12:00:00.000Z").getTime();
|
||||
const lastLaunched = new Date("2025-04-15T12:00:00.000Z").getTime();
|
||||
|
||||
const cipherData: CipherData = {
|
||||
id: "id",
|
||||
organizationId: "orgId",
|
||||
folderId: "folderId",
|
||||
edit: true,
|
||||
permissions: new CipherPermissionsApi(),
|
||||
collectionIds: [],
|
||||
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,
|
||||
reprompt: CipherRepromptType.None,
|
||||
key: "EncryptedString",
|
||||
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.Linked,
|
||||
linkedId: LoginLinkedId.Username,
|
||||
},
|
||||
{
|
||||
name: "EncryptedString",
|
||||
value: "EncryptedString",
|
||||
type: FieldType.Linked,
|
||||
linkedId: LoginLinkedId.Password,
|
||||
},
|
||||
],
|
||||
};
|
||||
const expectedCipher = new Cipher(cipherData, { lastUsedDate, lastLaunched });
|
||||
|
||||
const cipher = Cipher.fromSdkCipher(sdkCipher);
|
||||
|
||||
expect(cipher).toEqual(expectedCipher);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { uuidToString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { Cipher as SdkCipher } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
@@ -14,7 +15,7 @@ import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../../enums/cipher-type";
|
||||
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
|
||||
import { CipherData } from "../data/cipher.data";
|
||||
import { LocalData } from "../data/local.data";
|
||||
import { LocalData, fromSdkLocalData, toSdkLocalData } from "../data/local.data";
|
||||
import { AttachmentView } from "../view/attachment.view";
|
||||
import { CipherView } from "../view/cipher.view";
|
||||
import { FieldView } from "../view/field.view";
|
||||
@@ -361,16 +362,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
}
|
||||
: undefined,
|
||||
viewPassword: this.viewPassword ?? true,
|
||||
localData: this.localData
|
||||
? {
|
||||
lastUsedDate: this.localData.lastUsedDate
|
||||
? new Date(this.localData.lastUsedDate).toISOString()
|
||||
: undefined,
|
||||
lastLaunched: this.localData.lastLaunched
|
||||
? new Date(this.localData.lastLaunched).toISOString()
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
localData: toSdkLocalData(this.localData),
|
||||
attachments: this.attachments?.map((a) => a.toSdkAttachment()),
|
||||
fields: this.fields?.map((f) => f.toSdkField()),
|
||||
passwordHistory: this.passwordHistory?.map((ph) => ph.toSdkPasswordHistory()),
|
||||
@@ -408,4 +400,50 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
|
||||
return sdkCipher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an SDK Cipher object to a Cipher
|
||||
* @param sdkCipher - The SDK Cipher object
|
||||
*/
|
||||
static fromSdkCipher(sdkCipher: SdkCipher | null): Cipher | undefined {
|
||||
if (sdkCipher == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cipher = new Cipher();
|
||||
|
||||
cipher.id = sdkCipher.id ? uuidToString(sdkCipher.id) : undefined;
|
||||
cipher.organizationId = sdkCipher.organizationId
|
||||
? uuidToString(sdkCipher.organizationId)
|
||||
: undefined;
|
||||
cipher.folderId = sdkCipher.folderId ? uuidToString(sdkCipher.folderId) : undefined;
|
||||
cipher.collectionIds = sdkCipher.collectionIds ? sdkCipher.collectionIds.map(uuidToString) : [];
|
||||
cipher.key = EncString.fromJSON(sdkCipher.key);
|
||||
cipher.name = EncString.fromJSON(sdkCipher.name);
|
||||
cipher.notes = EncString.fromJSON(sdkCipher.notes);
|
||||
cipher.type = sdkCipher.type;
|
||||
cipher.favorite = sdkCipher.favorite;
|
||||
cipher.organizationUseTotp = sdkCipher.organizationUseTotp;
|
||||
cipher.edit = sdkCipher.edit;
|
||||
cipher.permissions = CipherPermissionsApi.fromSdkCipherPermissions(sdkCipher.permissions);
|
||||
cipher.viewPassword = sdkCipher.viewPassword;
|
||||
cipher.localData = fromSdkLocalData(sdkCipher.localData);
|
||||
cipher.attachments = sdkCipher.attachments?.map((a) => Attachment.fromSdkAttachment(a)) ?? [];
|
||||
cipher.fields = sdkCipher.fields?.map((f) => Field.fromSdkField(f)) ?? [];
|
||||
cipher.passwordHistory =
|
||||
sdkCipher.passwordHistory?.map((ph) => Password.fromSdkPasswordHistory(ph)) ?? [];
|
||||
cipher.creationDate = new Date(sdkCipher.creationDate);
|
||||
cipher.revisionDate = new Date(sdkCipher.revisionDate);
|
||||
cipher.deletedDate = sdkCipher.deletedDate ? new Date(sdkCipher.deletedDate) : null;
|
||||
cipher.reprompt = sdkCipher.reprompt;
|
||||
|
||||
// Cipher type specific properties
|
||||
cipher.login = Login.fromSdkLogin(sdkCipher.login);
|
||||
cipher.secureNote = SecureNote.fromSdkSecureNote(sdkCipher.secureNote);
|
||||
cipher.card = Card.fromSdkCard(sdkCipher.card);
|
||||
cipher.identity = Identity.fromSdkIdentity(sdkCipher.identity);
|
||||
cipher.sshKey = SshKey.fromSdkSshKey(sdkCipher.sshKey);
|
||||
|
||||
return cipher;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,4 +173,32 @@ export class Fido2Credential extends Domain {
|
||||
creationDate: this.creationDate.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an SDK Fido2Credential object to a Fido2Credential
|
||||
* @param obj - The SDK Fido2Credential object
|
||||
*/
|
||||
static fromSdkFido2Credential(obj: SdkFido2Credential): Fido2Credential | undefined {
|
||||
if (!obj) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const credential = new Fido2Credential();
|
||||
|
||||
credential.credentialId = EncString.fromJSON(obj.credentialId);
|
||||
credential.keyType = EncString.fromJSON(obj.keyType);
|
||||
credential.keyAlgorithm = EncString.fromJSON(obj.keyAlgorithm);
|
||||
credential.keyCurve = EncString.fromJSON(obj.keyCurve);
|
||||
credential.keyValue = EncString.fromJSON(obj.keyValue);
|
||||
credential.rpId = EncString.fromJSON(obj.rpId);
|
||||
credential.userHandle = EncString.fromJSON(obj.userHandle);
|
||||
credential.userName = EncString.fromJSON(obj.userName);
|
||||
credential.counter = EncString.fromJSON(obj.counter);
|
||||
credential.rpName = EncString.fromJSON(obj.rpName);
|
||||
credential.userDisplayName = EncString.fromJSON(obj.userDisplayName);
|
||||
credential.discoverable = EncString.fromJSON(obj.discoverable);
|
||||
credential.creationDate = new Date(obj.creationDate);
|
||||
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import {
|
||||
Field as SdkField,
|
||||
FieldType,
|
||||
LoginLinkedIdType,
|
||||
CardLinkedIdType,
|
||||
IdentityLinkedIdType,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { mockEnc, mockFromJson } from "../../../../spec";
|
||||
import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { CardLinkedId, FieldType, IdentityLinkedId, LoginLinkedId } from "../../enums";
|
||||
import { CardLinkedId, IdentityLinkedId, LoginLinkedId } from "../../enums";
|
||||
import { FieldData } from "../../models/data/field.data";
|
||||
import { Field } from "../../models/domain/field";
|
||||
|
||||
@@ -103,5 +111,34 @@ describe("Field", () => {
|
||||
identityField.linkedId = IdentityLinkedId.LicenseNumber;
|
||||
expect(identityField.toSdkField().linkedId).toBe(415);
|
||||
});
|
||||
|
||||
it("should map from SDK Field", () => {
|
||||
// Test Login LinkedId
|
||||
const loginField: SdkField = {
|
||||
name: undefined,
|
||||
value: undefined,
|
||||
type: FieldType.Linked,
|
||||
linkedId: LoginLinkedIdType.Username,
|
||||
};
|
||||
expect(Field.fromSdkField(loginField)!.linkedId).toBe(100);
|
||||
|
||||
// Test Card LinkedId
|
||||
const cardField: SdkField = {
|
||||
name: undefined,
|
||||
value: undefined,
|
||||
type: FieldType.Linked,
|
||||
linkedId: CardLinkedIdType.Number,
|
||||
};
|
||||
expect(Field.fromSdkField(cardField)!.linkedId).toBe(305);
|
||||
|
||||
// Test Identity LinkedId
|
||||
const identityFieldSdkField: SdkField = {
|
||||
name: undefined,
|
||||
value: undefined,
|
||||
type: FieldType.Linked,
|
||||
linkedId: IdentityLinkedIdType.LicenseNumber,
|
||||
};
|
||||
expect(Field.fromSdkField(identityFieldSdkField)!.linkedId).toBe(415);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,4 +90,22 @@ export class Field extends Domain {
|
||||
linkedId: this.linkedId as unknown as SdkLinkedIdType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps SDK Field to Field
|
||||
* @param obj The SDK Field object to map
|
||||
*/
|
||||
static fromSdkField(obj: SdkField): Field | undefined {
|
||||
if (!obj) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const field = new Field();
|
||||
field.name = EncString.fromJSON(obj.name);
|
||||
field.value = EncString.fromJSON(obj.value);
|
||||
field.type = obj.type;
|
||||
field.linkedId = obj.linkedId;
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,4 +195,36 @@ export class Identity extends Domain {
|
||||
licenseNumber: this.licenseNumber?.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an SDK Identity object to an Identity
|
||||
* @param obj - The SDK Identity object
|
||||
*/
|
||||
static fromSdkIdentity(obj: SdkIdentity): Identity | undefined {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const identity = new Identity();
|
||||
identity.title = EncString.fromJSON(obj.title);
|
||||
identity.firstName = EncString.fromJSON(obj.firstName);
|
||||
identity.middleName = EncString.fromJSON(obj.middleName);
|
||||
identity.lastName = EncString.fromJSON(obj.lastName);
|
||||
identity.address1 = EncString.fromJSON(obj.address1);
|
||||
identity.address2 = EncString.fromJSON(obj.address2);
|
||||
identity.address3 = EncString.fromJSON(obj.address3);
|
||||
identity.city = EncString.fromJSON(obj.city);
|
||||
identity.state = EncString.fromJSON(obj.state);
|
||||
identity.postalCode = EncString.fromJSON(obj.postalCode);
|
||||
identity.country = EncString.fromJSON(obj.country);
|
||||
identity.company = EncString.fromJSON(obj.company);
|
||||
identity.email = EncString.fromJSON(obj.email);
|
||||
identity.phone = EncString.fromJSON(obj.phone);
|
||||
identity.ssn = EncString.fromJSON(obj.ssn);
|
||||
identity.username = EncString.fromJSON(obj.username);
|
||||
identity.passportNumber = EncString.fromJSON(obj.passportNumber);
|
||||
identity.licenseNumber = EncString.fromJSON(obj.licenseNumber);
|
||||
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,4 +102,17 @@ export class LoginUri extends Domain {
|
||||
match: this.match,
|
||||
};
|
||||
}
|
||||
|
||||
static fromSdkLoginUri(obj: SdkLoginUri): LoginUri | undefined {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const view = new LoginUri();
|
||||
view.uri = EncString.fromJSON(obj.uri);
|
||||
view.uriChecksum = obj.uriChecksum ? EncString.fromJSON(obj.uriChecksum) : undefined;
|
||||
view.match = obj.match;
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,4 +163,31 @@ export class Login extends Domain {
|
||||
fido2Credentials: this.fido2Credentials?.map((f) => f.toSdkFido2Credential()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an SDK Login object to a Login
|
||||
* @param obj - The SDK Login object
|
||||
*/
|
||||
static fromSdkLogin(obj: SdkLogin): Login | undefined {
|
||||
if (!obj) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const login = new Login();
|
||||
|
||||
login.uris =
|
||||
obj.uris?.filter((u) => u.uri != null).map((uri) => LoginUri.fromSdkLoginUri(uri)) ?? [];
|
||||
login.username = EncString.fromJSON(obj.username);
|
||||
login.password = EncString.fromJSON(obj.password);
|
||||
login.passwordRevisionDate = obj.passwordRevisionDate
|
||||
? new Date(obj.passwordRevisionDate)
|
||||
: undefined;
|
||||
login.totp = EncString.fromJSON(obj.totp);
|
||||
login.autofillOnPageLoad = obj.autofillOnPageLoad ?? false;
|
||||
login.fido2Credentials = obj.fido2Credentials?.map((f) =>
|
||||
Fido2Credential.fromSdkFido2Credential(f),
|
||||
);
|
||||
|
||||
return login;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,4 +71,20 @@ export class Password extends Domain {
|
||||
lastUsedDate: this.lastUsedDate.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an SDK PasswordHistory object to a Password
|
||||
* @param obj - The SDK PasswordHistory object
|
||||
*/
|
||||
static fromSdkPasswordHistory(obj: PasswordHistory): Password | undefined {
|
||||
if (!obj) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const passwordHistory = new Password();
|
||||
passwordHistory.password = EncString.fromJSON(obj.password);
|
||||
passwordHistory.lastUsedDate = new Date(obj.lastUsedDate);
|
||||
|
||||
return passwordHistory;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,4 +54,19 @@ export class SecureNote extends Domain {
|
||||
type: this.type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an SDK SecureNote object to a SecureNote
|
||||
* @param obj - The SDK SecureNote object
|
||||
*/
|
||||
static fromSdkSecureNote(obj: SdkSecureNote): SecureNote | undefined {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const secureNote = new SecureNote();
|
||||
secureNote.type = obj.type;
|
||||
|
||||
return secureNote;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,4 +85,21 @@ export class SshKey extends Domain {
|
||||
fingerprint: this.keyFingerprint.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an SDK SshKey object to a SshKey
|
||||
* @param obj - The SDK SshKey object
|
||||
*/
|
||||
static fromSdkSshKey(obj: SdkSshKey): SshKey | undefined {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sshKey = new SshKey();
|
||||
sshKey.privateKey = EncString.fromJSON(obj.privateKey);
|
||||
sshKey.publicKey = EncString.fromJSON(obj.publicKey);
|
||||
sshKey.keyFingerprint = EncString.fromJSON(obj.fingerprint);
|
||||
|
||||
return sshKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,6 @@ export class TreeNode<T extends ITreeNodeObject> {
|
||||
}
|
||||
|
||||
export interface ITreeNodeObject {
|
||||
id: string;
|
||||
name: string;
|
||||
id: string | undefined;
|
||||
name: string | undefined;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { linkedFieldOption } from "../../linked-field-option.decorator";
|
||||
|
||||
import { ItemView } from "./item.view";
|
||||
|
||||
export class CardView extends ItemView {
|
||||
export class CardView extends ItemView implements SdkCardView {
|
||||
@linkedFieldOption(LinkedId.CardholderName, { sortPosition: 0 })
|
||||
cardholderName: string = null;
|
||||
@linkedFieldOption(LinkedId.ExpMonth, { sortPosition: 3, i18nKey: "expirationMonth" })
|
||||
@@ -168,4 +168,12 @@ export class CardView extends ItemView {
|
||||
|
||||
return cardView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the CardView to an SDK CardView.
|
||||
* The view implements the SdkView so we can safely return `this`
|
||||
*/
|
||||
toSdkCardView(): SdkCardView {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { CipherPermissionsApi } from "@bitwarden/common/vault/models/api/cipher-permissions.api";
|
||||
import {
|
||||
CipherView as SdkCipherView,
|
||||
CipherType as SdkCipherType,
|
||||
@@ -85,6 +89,25 @@ describe("CipherView", () => {
|
||||
|
||||
expect(actual).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it("handle both string and object inputs for the cipher key", () => {
|
||||
const cipherKeyString = "cipherKeyString";
|
||||
const cipherKeyObject = new EncString("cipherKeyObject");
|
||||
|
||||
// Test with string input
|
||||
let actual = CipherView.fromJSON({
|
||||
key: cipherKeyString,
|
||||
});
|
||||
expect(actual.key).toBeInstanceOf(EncString);
|
||||
expect(actual.key?.toJSON()).toBe(cipherKeyString);
|
||||
|
||||
// Test with object input (which can happen when cipher view is stored in an InMemory state provider)
|
||||
actual = CipherView.fromJSON({
|
||||
key: cipherKeyObject,
|
||||
} as Jsonify<CipherView>);
|
||||
expect(actual.key).toBeInstanceOf(EncString);
|
||||
expect(actual.key?.toJSON()).toBe(cipherKeyObject.toJSON());
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkCipherView", () => {
|
||||
@@ -196,11 +219,80 @@ describe("CipherView", () => {
|
||||
__fromSdk: true,
|
||||
},
|
||||
],
|
||||
passwordHistory: null,
|
||||
passwordHistory: [],
|
||||
creationDate: new Date("2022-01-01T12:00:00.000Z"),
|
||||
revisionDate: new Date("2022-01-02T12:00:00.000Z"),
|
||||
deletedDate: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSdkCipherView", () => {
|
||||
it("maps properties correctly", () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c";
|
||||
cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f";
|
||||
cipherView.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"];
|
||||
cipherView.key = new EncString("some-key");
|
||||
cipherView.name = "name";
|
||||
cipherView.notes = "notes";
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.favorite = true;
|
||||
cipherView.edit = true;
|
||||
cipherView.viewPassword = false;
|
||||
cipherView.reprompt = CipherRepromptType.None;
|
||||
cipherView.organizationUseTotp = false;
|
||||
cipherView.localData = {
|
||||
lastLaunched: new Date("2022-01-01T12:00:00.000Z").getTime(),
|
||||
lastUsedDate: new Date("2022-01-02T12:00:00.000Z").getTime(),
|
||||
};
|
||||
cipherView.permissions = new CipherPermissionsApi();
|
||||
cipherView.permissions.restore = true;
|
||||
cipherView.permissions.delete = true;
|
||||
cipherView.attachments = [];
|
||||
cipherView.fields = [];
|
||||
cipherView.passwordHistory = [];
|
||||
cipherView.login = new LoginView();
|
||||
cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z");
|
||||
cipherView.creationDate = new Date("2022-01-02T12:00:00.000Z");
|
||||
|
||||
const sdkCipherView = cipherView.toSdkCipherView();
|
||||
|
||||
expect(sdkCipherView).toMatchObject({
|
||||
id: "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602",
|
||||
organizationId: "000f2a6e-da5e-4726-87ed-1c5c77322c3c",
|
||||
folderId: "41b22db4-8e2a-4ed2-b568-f1186c72922f",
|
||||
collectionIds: ["b0473506-3c3c-4260-a734-dfaaf833ab6f"],
|
||||
key: "some-key",
|
||||
name: "name",
|
||||
notes: "notes",
|
||||
type: SdkCipherType.Login,
|
||||
favorite: true,
|
||||
edit: true,
|
||||
viewPassword: false,
|
||||
reprompt: SdkCipherRepromptType.None,
|
||||
organizationUseTotp: false,
|
||||
localData: {
|
||||
lastLaunched: "2022-01-01T12:00:00.000Z",
|
||||
lastUsedDate: "2022-01-02T12:00:00.000Z",
|
||||
},
|
||||
permissions: {
|
||||
restore: true,
|
||||
delete: true,
|
||||
},
|
||||
deletedDate: undefined,
|
||||
creationDate: "2022-01-02T12:00:00.000Z",
|
||||
revisionDate: "2022-01-02T12:00:00.000Z",
|
||||
attachments: [],
|
||||
passwordHistory: [],
|
||||
login: undefined,
|
||||
identity: undefined,
|
||||
card: undefined,
|
||||
secureNote: undefined,
|
||||
sshKey: undefined,
|
||||
fields: [],
|
||||
} as SdkCipherView);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { uuidToString, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { View } from "../../../models/view/view";
|
||||
@@ -9,7 +11,7 @@ import { DeepJsonify } from "../../../types/deep-jsonify";
|
||||
import { CipherType, LinkedIdType } from "../../enums";
|
||||
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
|
||||
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
|
||||
import { LocalData } from "../data/local.data";
|
||||
import { LocalData, toSdkLocalData, fromSdkLocalData } from "../data/local.data";
|
||||
import { Cipher } from "../domain/cipher";
|
||||
|
||||
import { AttachmentView } from "./attachment.view";
|
||||
@@ -41,14 +43,17 @@ export class CipherView implements View, InitializerMetadata {
|
||||
card = new CardView();
|
||||
secureNote = new SecureNoteView();
|
||||
sshKey = new SshKeyView();
|
||||
attachments: AttachmentView[] = null;
|
||||
fields: FieldView[] = null;
|
||||
passwordHistory: PasswordHistoryView[] = null;
|
||||
attachments: AttachmentView[] = [];
|
||||
fields: FieldView[] = [];
|
||||
passwordHistory: PasswordHistoryView[] = [];
|
||||
collectionIds: string[] = null;
|
||||
revisionDate: Date = null;
|
||||
creationDate: Date = null;
|
||||
deletedDate: Date = null;
|
||||
reprompt: CipherRepromptType = CipherRepromptType.None;
|
||||
// We need a copy of the encrypted key so we can pass it to
|
||||
// the SdkCipherView during encryption
|
||||
key?: EncString;
|
||||
|
||||
/**
|
||||
* Flag to indicate if the cipher decryption failed.
|
||||
@@ -76,6 +81,7 @@ export class CipherView implements View, InitializerMetadata {
|
||||
this.deletedDate = c.deletedDate;
|
||||
// Old locally stored ciphers might have reprompt == null. If so set it to None.
|
||||
this.reprompt = c.reprompt ?? CipherRepromptType.None;
|
||||
this.key = c.key;
|
||||
}
|
||||
|
||||
private get item() {
|
||||
@@ -194,6 +200,18 @@ export class CipherView implements View, InitializerMetadata {
|
||||
const attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a));
|
||||
const fields = obj.fields?.map((f: any) => FieldView.fromJSON(f));
|
||||
const passwordHistory = obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph));
|
||||
const permissions = CipherPermissionsApi.fromJSON(obj.permissions);
|
||||
let key: EncString | undefined;
|
||||
|
||||
if (obj.key != null) {
|
||||
if (typeof obj.key === "string") {
|
||||
// If the key is a string, we need to parse it as EncString
|
||||
key = EncString.fromJSON(obj.key);
|
||||
} else if ((obj.key as any) instanceof EncString) {
|
||||
// If the key is already an EncString instance, we can use it directly
|
||||
key = obj.key;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(view, obj, {
|
||||
creationDate: creationDate,
|
||||
@@ -202,6 +220,8 @@ export class CipherView implements View, InitializerMetadata {
|
||||
attachments: attachments,
|
||||
fields: fields,
|
||||
passwordHistory: passwordHistory,
|
||||
permissions: permissions,
|
||||
key: key,
|
||||
});
|
||||
|
||||
switch (obj.type) {
|
||||
@@ -236,9 +256,9 @@ export class CipherView implements View, InitializerMetadata {
|
||||
}
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = obj.id ?? null;
|
||||
cipherView.organizationId = obj.organizationId ?? null;
|
||||
cipherView.folderId = obj.folderId ?? null;
|
||||
cipherView.id = uuidToString(obj.id) ?? null;
|
||||
cipherView.organizationId = uuidToString(obj.organizationId) ?? null;
|
||||
cipherView.folderId = uuidToString(obj.folderId) ?? null;
|
||||
cipherView.name = obj.name;
|
||||
cipherView.notes = obj.notes ?? null;
|
||||
cipherView.type = obj.type;
|
||||
@@ -247,26 +267,18 @@ export class CipherView implements View, InitializerMetadata {
|
||||
cipherView.permissions = CipherPermissionsApi.fromSdkCipherPermissions(obj.permissions);
|
||||
cipherView.edit = obj.edit;
|
||||
cipherView.viewPassword = obj.viewPassword;
|
||||
cipherView.localData = obj.localData
|
||||
? {
|
||||
lastUsedDate: obj.localData.lastUsedDate
|
||||
? new Date(obj.localData.lastUsedDate).getTime()
|
||||
: undefined,
|
||||
lastLaunched: obj.localData.lastLaunched
|
||||
? new Date(obj.localData.lastLaunched).getTime()
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
cipherView.localData = fromSdkLocalData(obj.localData);
|
||||
cipherView.attachments =
|
||||
obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)) ?? null;
|
||||
cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)) ?? null;
|
||||
obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)) ?? [];
|
||||
cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)) ?? [];
|
||||
cipherView.passwordHistory =
|
||||
obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)) ?? null;
|
||||
cipherView.collectionIds = obj.collectionIds ?? null;
|
||||
obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)) ?? [];
|
||||
cipherView.collectionIds = obj.collectionIds?.map((i) => uuidToString(i)) ?? [];
|
||||
cipherView.revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
|
||||
cipherView.creationDate = obj.creationDate == null ? null : new Date(obj.creationDate);
|
||||
cipherView.deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
|
||||
cipherView.reprompt = obj.reprompt ?? CipherRepromptType.None;
|
||||
cipherView.key = EncString.fromJSON(obj.key);
|
||||
|
||||
switch (obj.type) {
|
||||
case CipherType.Card:
|
||||
@@ -290,4 +302,66 @@ export class CipherView implements View, InitializerMetadata {
|
||||
|
||||
return cipherView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CipherView to SdkCipherView
|
||||
*
|
||||
* @returns {SdkCipherView} The SDK cipher view object
|
||||
*/
|
||||
toSdkCipherView(): SdkCipherView {
|
||||
const sdkCipherView: SdkCipherView = {
|
||||
id: this.id ? asUuid(this.id) : undefined,
|
||||
organizationId: this.organizationId ? asUuid(this.organizationId) : undefined,
|
||||
folderId: this.folderId ? asUuid(this.folderId) : undefined,
|
||||
name: this.name ?? "",
|
||||
notes: this.notes,
|
||||
type: this.type ?? CipherType.Login,
|
||||
favorite: this.favorite,
|
||||
organizationUseTotp: this.organizationUseTotp,
|
||||
permissions: this.permissions?.toSdkCipherPermissions(),
|
||||
edit: this.edit,
|
||||
viewPassword: this.viewPassword,
|
||||
localData: toSdkLocalData(this.localData),
|
||||
attachments: this.attachments?.map((a) => a.toSdkAttachmentView()),
|
||||
fields: this.fields?.map((f) => f.toSdkFieldView()),
|
||||
passwordHistory: this.passwordHistory?.map((ph) => ph.toSdkPasswordHistoryView()),
|
||||
collectionIds: this.collectionIds?.map((i) => i) ?? [],
|
||||
// Revision and creation dates are non-nullable in SDKCipherView
|
||||
revisionDate: (this.revisionDate ?? new Date()).toISOString(),
|
||||
creationDate: (this.creationDate ?? new Date()).toISOString(),
|
||||
deletedDate: this.deletedDate?.toISOString(),
|
||||
reprompt: this.reprompt ?? CipherRepromptType.None,
|
||||
key: this.key?.toJSON(),
|
||||
// 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
|
||||
login: undefined,
|
||||
card: undefined,
|
||||
identity: undefined,
|
||||
secureNote: undefined,
|
||||
sshKey: undefined,
|
||||
};
|
||||
|
||||
switch (this.type) {
|
||||
case CipherType.Card:
|
||||
sdkCipherView.card = this.card.toSdkCardView();
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
sdkCipherView.identity = this.identity.toSdkIdentityView();
|
||||
break;
|
||||
case CipherType.Login:
|
||||
sdkCipherView.login = this.login.toSdkLoginView();
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
sdkCipherView.secureNote = this.secureNote.toSdkSecureNoteView();
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
sdkCipherView.sshKey = this.sshKey.toSdkSshKeyView();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return sdkCipherView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Fido2CredentialView as SdkFido2CredentialView } from "@bitwarden/sdk-internal";
|
||||
import {
|
||||
Fido2CredentialView as SdkFido2CredentialView,
|
||||
Fido2CredentialFullView,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { ItemView } from "./item.view";
|
||||
|
||||
@@ -56,4 +59,22 @@ export class Fido2CredentialView extends ItemView {
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
toSdkFido2CredentialFullView(): Fido2CredentialFullView {
|
||||
return {
|
||||
credentialId: this.credentialId,
|
||||
keyType: this.keyType,
|
||||
keyAlgorithm: this.keyAlgorithm,
|
||||
keyCurve: this.keyCurve,
|
||||
keyValue: this.keyValue,
|
||||
rpId: this.rpId,
|
||||
userHandle: this.userHandle,
|
||||
userName: this.userName,
|
||||
counter: this.counter.toString(),
|
||||
rpName: this.rpName,
|
||||
userDisplayName: this.userDisplayName,
|
||||
discoverable: this.discoverable ? "true" : "false",
|
||||
creationDate: this.creationDate?.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { FieldView as SdkFieldView } from "@bitwarden/sdk-internal";
|
||||
import { FieldView as SdkFieldView, FieldType as SdkFieldType } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { View } from "../../../models/view/view";
|
||||
import { FieldType, LinkedIdType } from "../../enums";
|
||||
@@ -50,4 +50,16 @@ export class FieldView implements View {
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the FieldView to an SDK FieldView.
|
||||
*/
|
||||
toSdkFieldView(): SdkFieldView {
|
||||
return {
|
||||
name: this.name ?? undefined,
|
||||
value: this.value ?? undefined,
|
||||
type: this.type ?? SdkFieldType.Text,
|
||||
linkedId: this.linkedId ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { linkedFieldOption } from "../../linked-field-option.decorator";
|
||||
|
||||
import { ItemView } from "./item.view";
|
||||
|
||||
export class IdentityView extends ItemView {
|
||||
export class IdentityView extends ItemView implements SdkIdentityView {
|
||||
@linkedFieldOption(LinkedId.Title, { sortPosition: 0 })
|
||||
title: string = null;
|
||||
@linkedFieldOption(LinkedId.MiddleName, { sortPosition: 2 })
|
||||
@@ -192,4 +192,12 @@ export class IdentityView extends ItemView {
|
||||
|
||||
return identityView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the IdentityView to an SDK IdentityView.
|
||||
* The view implements the SdkView so we can safely return `this`
|
||||
*/
|
||||
toSdkIdentityView(): SdkIdentityView {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,15 @@ export class LoginUriView implements View {
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Converts a LoginUriView object to an SDK LoginUriView object. */
|
||||
toSdkLoginUriView(): SdkLoginUriView {
|
||||
return {
|
||||
uri: this.uri ?? undefined,
|
||||
match: this.match ?? undefined,
|
||||
uriChecksum: undefined, // SDK handles uri checksum generation internally
|
||||
};
|
||||
}
|
||||
|
||||
matchesUri(
|
||||
targetUri: string,
|
||||
equivalentDomains: Set<string>,
|
||||
|
||||
@@ -124,10 +124,30 @@ export class LoginView extends ItemView {
|
||||
obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate);
|
||||
loginView.totp = obj.totp ?? null;
|
||||
loginView.autofillOnPageLoad = obj.autofillOnPageLoad ?? null;
|
||||
loginView.uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || [];
|
||||
loginView.uris =
|
||||
obj.uris
|
||||
?.filter((uri) => uri.uri != null && uri.uri !== "")
|
||||
.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || [];
|
||||
// FIDO2 credentials are not decrypted here, they remain encrypted
|
||||
loginView.fido2Credentials = null;
|
||||
|
||||
return loginView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the LoginView to an SDK LoginView.
|
||||
*
|
||||
* Note: FIDO2 credentials remain encrypted in the SDK view so they are not included here.
|
||||
*/
|
||||
toSdkLoginView(): SdkLoginView {
|
||||
return {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
passwordRevisionDate: this.passwordRevisionDate?.toISOString(),
|
||||
totp: this.totp,
|
||||
autofillOnPageLoad: this.autofillOnPageLoad ?? undefined,
|
||||
uris: this.uris?.map((uri) => uri.toSdkLoginUriView()),
|
||||
fido2Credentials: undefined, // FIDO2 credentials are handled separately and remain encrypted
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,4 +33,17 @@ describe("PasswordHistoryView", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSdkPasswordHistoryView", () => {
|
||||
it("should return a SdkPasswordHistoryView", () => {
|
||||
const passwordHistoryView = new PasswordHistoryView();
|
||||
passwordHistoryView.password = "password";
|
||||
passwordHistoryView.lastUsedDate = new Date("2023-10-01T00:00:00.000Z");
|
||||
|
||||
expect(passwordHistoryView.toSdkPasswordHistoryView()).toMatchObject({
|
||||
password: "password",
|
||||
lastUsedDate: "2023-10-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,4 +41,14 @@ export class PasswordHistoryView implements View {
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the PasswordHistoryView to an SDK PasswordHistoryView.
|
||||
*/
|
||||
toSdkPasswordHistoryView(): SdkPasswordHistoryView {
|
||||
return {
|
||||
password: this.password ?? "",
|
||||
lastUsedDate: this.lastUsedDate.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SecureNote } from "../domain/secure-note";
|
||||
|
||||
import { ItemView } from "./item.view";
|
||||
|
||||
export class SecureNoteView extends ItemView {
|
||||
export class SecureNoteView extends ItemView implements SdkSecureNoteView {
|
||||
type: SecureNoteType = null;
|
||||
|
||||
constructor(n?: SecureNote) {
|
||||
@@ -42,4 +42,12 @@ export class SecureNoteView extends ItemView {
|
||||
|
||||
return secureNoteView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the SecureNoteView to an SDK SecureNoteView.
|
||||
* The view implements the SdkView so we can safely return `this`
|
||||
*/
|
||||
toSdkSecureNoteView(): SdkSecureNoteView {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,4 +63,15 @@ export class SshKeyView extends ItemView {
|
||||
|
||||
return sshKeyView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the SshKeyView to an SDK SshKeyView.
|
||||
*/
|
||||
toSdkSshKeyView(): SdkSshKeyView {
|
||||
return {
|
||||
privateKey: this.privateKey,
|
||||
publicKey: this.publicKey,
|
||||
fingerprint: this.keyFingerprint,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, 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";
|
||||
@@ -23,7 +25,7 @@ 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 } from "../../types/guid";
|
||||
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";
|
||||
@@ -108,6 +110,7 @@ describe("Cipher Service", () => {
|
||||
const cipherEncryptionService = mock<CipherEncryptionService>();
|
||||
|
||||
const userId = "TestUserId" as UserId;
|
||||
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId;
|
||||
|
||||
let cipherService: CipherService;
|
||||
let encryptionContext: EncryptionContext;
|
||||
@@ -155,7 +158,9 @@ describe("Cipher Service", () => {
|
||||
);
|
||||
|
||||
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false));
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.CipherKeyEncryption)
|
||||
.mockResolvedValue(false);
|
||||
|
||||
const spy = jest.spyOn(cipherFileUploadService, "upload");
|
||||
|
||||
@@ -270,6 +275,55 @@ describe("Cipher Service", () => {
|
||||
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),
|
||||
@@ -310,7 +364,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("is null when feature flag is false", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.CipherKeyEncryption)
|
||||
.mockResolvedValue(false);
|
||||
const { cipher } = await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
expect(cipher.key).toBeNull();
|
||||
@@ -318,7 +374,9 @@ describe("Cipher Service", () => {
|
||||
|
||||
describe("when feature flag is true", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.CipherKeyEncryption)
|
||||
.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("is null when the cipher is not viewPassword", async () => {
|
||||
@@ -348,7 +406,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("is not called when feature flag is false", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.CipherKeyEncryption)
|
||||
.mockResolvedValue(false);
|
||||
|
||||
await cipherService.encrypt(cipherView, userId);
|
||||
|
||||
@@ -357,7 +417,9 @@ describe("Cipher Service", () => {
|
||||
|
||||
describe("when feature flag is true", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.CipherKeyEncryption)
|
||||
.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("is called when cipher viewPassword is true", async () => {
|
||||
@@ -401,7 +463,9 @@ describe("Cipher Service", () => {
|
||||
let encryptedKey: EncString;
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.CipherKeyEncryption)
|
||||
.mockResolvedValue(true);
|
||||
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
|
||||
|
||||
searchService.indexedEntityId$.mockReturnValue(of(null));
|
||||
@@ -474,7 +538,9 @@ describe("Cipher Service", () => {
|
||||
|
||||
describe("decrypt", () => {
|
||||
it("should call decrypt method of CipherEncryptionService when feature flag is true", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
|
||||
.mockResolvedValue(true);
|
||||
cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(encryptionContext.cipher));
|
||||
|
||||
const result = await cipherService.decrypt(encryptionContext.cipher, userId);
|
||||
@@ -488,7 +554,9 @@ describe("Cipher Service", () => {
|
||||
|
||||
it("should call legacy decrypt when feature flag is false", async () => {
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
|
||||
.mockResolvedValue(false);
|
||||
cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey);
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||
jest
|
||||
@@ -509,7 +577,9 @@ describe("Cipher Service", () => {
|
||||
it("should use SDK when feature flag is enabled", async () => {
|
||||
const cipher = new Cipher(cipherData);
|
||||
const attachment = new AttachmentView(cipher.attachments![0]);
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
jest.spyOn(cipherService, "ciphers$").mockReturnValue(of({ [cipher.id]: cipherData }));
|
||||
cipherEncryptionService.decryptAttachmentContent.mockResolvedValue(mockDecryptedContent);
|
||||
@@ -534,7 +604,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should use legacy decryption when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
|
||||
.mockResolvedValue(false);
|
||||
const cipher = new Cipher(cipherData);
|
||||
const attachment = new AttachmentView(cipher.attachments![0]);
|
||||
attachment.key = makeSymmetricCryptoKey(64);
|
||||
@@ -557,4 +629,77 @@ describe("Cipher Service", () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,13 +231,14 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
this.clearCipherViewsForUser$.next(userId);
|
||||
}
|
||||
|
||||
async encrypt(
|
||||
model: CipherView,
|
||||
userId: UserId,
|
||||
keyForCipherEncryption?: SymmetricCryptoKey,
|
||||
keyForCipherKeyDecryption?: SymmetricCryptoKey,
|
||||
originalCipher: Cipher = null,
|
||||
): Promise<EncryptionContext> {
|
||||
/**
|
||||
* Adjusts the cipher history for the given model by updating its history properties based on the original cipher.
|
||||
* @param model The cipher model to adjust.
|
||||
* @param userId The acting userId
|
||||
* @param originalCipher The original cipher to compare against. If not provided, it will be fetched from the store.
|
||||
* @private
|
||||
*/
|
||||
private async adjustCipherHistory(model: CipherView, userId: UserId, originalCipher?: Cipher) {
|
||||
if (model.id != null) {
|
||||
if (originalCipher == null) {
|
||||
originalCipher = await this.get(model.id, userId);
|
||||
@@ -247,6 +248,25 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
this.adjustPasswordHistoryLength(model);
|
||||
}
|
||||
}
|
||||
|
||||
async encrypt(
|
||||
model: CipherView,
|
||||
userId: UserId,
|
||||
keyForCipherEncryption?: SymmetricCryptoKey,
|
||||
keyForCipherKeyDecryption?: SymmetricCryptoKey,
|
||||
originalCipher: Cipher = null,
|
||||
): Promise<EncryptionContext> {
|
||||
await this.adjustCipherHistory(model, userId, originalCipher);
|
||||
|
||||
const sdkEncryptionEnabled =
|
||||
(await this.configService.getFeatureFlag(FeatureFlag.PM22136_SdkCipherEncryption)) &&
|
||||
keyForCipherEncryption == null && // PM-23085 - SDK encryption does not currently support custom keys (e.g. key rotation)
|
||||
keyForCipherKeyDecryption == null; // PM-23348 - Or has explicit methods for re-encrypting ciphers with different keys (e.g. move to org)
|
||||
|
||||
if (sdkEncryptionEnabled) {
|
||||
return await this.cipherEncryptionService.encrypt(model, userId);
|
||||
}
|
||||
|
||||
const cipher = new Cipher();
|
||||
cipher.id = model.id;
|
||||
@@ -854,22 +874,48 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
organizationId: string,
|
||||
collectionIds: string[],
|
||||
userId: UserId,
|
||||
originalCipher?: Cipher,
|
||||
): Promise<Cipher> {
|
||||
const attachmentPromises: Promise<any>[] = [];
|
||||
if (cipher.attachments != null) {
|
||||
cipher.attachments.forEach((attachment) => {
|
||||
if (attachment.key == null) {
|
||||
attachmentPromises.push(
|
||||
this.shareAttachmentWithServer(attachment, cipher.id, organizationId),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
await Promise.all(attachmentPromises);
|
||||
const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM22136_SdkCipherEncryption,
|
||||
);
|
||||
|
||||
await this.adjustCipherHistory(cipher, userId, originalCipher);
|
||||
|
||||
let encCipher: EncryptionContext;
|
||||
if (sdkCipherEncryptionEnabled) {
|
||||
// The SDK does not expect the cipher to already have an organizationId. It will result in the wrong
|
||||
// cipher encryption key being used during the move to organization operation.
|
||||
if (cipher.organizationId != null) {
|
||||
throw new Error("Cipher is already associated with an organization.");
|
||||
}
|
||||
|
||||
encCipher = await this.cipherEncryptionService.moveToOrganization(
|
||||
cipher,
|
||||
organizationId as OrganizationId,
|
||||
userId,
|
||||
);
|
||||
encCipher.cipher.collectionIds = collectionIds;
|
||||
} else {
|
||||
// This old attachment logic is safe to remove after it is replaced in PM-22750; which will require fixing
|
||||
// the attachment before sharing.
|
||||
const attachmentPromises: Promise<any>[] = [];
|
||||
if (cipher.attachments != null) {
|
||||
cipher.attachments.forEach((attachment) => {
|
||||
if (attachment.key == null) {
|
||||
attachmentPromises.push(
|
||||
this.shareAttachmentWithServer(attachment, cipher.id, organizationId),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
await Promise.all(attachmentPromises);
|
||||
|
||||
cipher.organizationId = organizationId;
|
||||
cipher.collectionIds = collectionIds;
|
||||
encCipher = await this.encryptSharedCipher(cipher, userId);
|
||||
}
|
||||
|
||||
cipher.organizationId = organizationId;
|
||||
cipher.collectionIds = collectionIds;
|
||||
const encCipher = await this.encryptSharedCipher(cipher, userId);
|
||||
const request = new CipherShareRequest(encCipher);
|
||||
const response = await this.apiService.putShareCipher(cipher.id, request);
|
||||
const data = new CipherData(response, collectionIds);
|
||||
@@ -883,16 +929,36 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
collectionIds: string[],
|
||||
userId: UserId,
|
||||
) {
|
||||
const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM22136_SdkCipherEncryption,
|
||||
);
|
||||
const promises: Promise<any>[] = [];
|
||||
const encCiphers: Cipher[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
cipher.organizationId = organizationId;
|
||||
cipher.collectionIds = collectionIds;
|
||||
promises.push(
|
||||
this.encryptSharedCipher(cipher, userId).then((c) => {
|
||||
encCiphers.push(c.cipher);
|
||||
}),
|
||||
);
|
||||
if (sdkCipherEncryptionEnabled) {
|
||||
// The SDK does not expect the cipher to already have an organizationId. It will result in the wrong
|
||||
// cipher encryption key being used during the move to organization operation.
|
||||
if (cipher.organizationId != null) {
|
||||
throw new Error("Cipher is already associated with an organization.");
|
||||
}
|
||||
|
||||
promises.push(
|
||||
this.cipherEncryptionService
|
||||
.moveToOrganization(cipher, organizationId as OrganizationId, userId)
|
||||
.then((encCipher) => {
|
||||
encCipher.cipher.collectionIds = collectionIds;
|
||||
encCiphers.push(encCipher.cipher);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
cipher.organizationId = organizationId;
|
||||
cipher.collectionIds = collectionIds;
|
||||
promises.push(
|
||||
this.encryptSharedCipher(cipher, userId).then((c) => {
|
||||
encCiphers.push(c.cipher);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
const request = new CipherBulkShareRequest(encCiphers, collectionIds, userId);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { EMPTY, catchError, firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { CipherListView } from "@bitwarden/sdk-internal";
|
||||
import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import {
|
||||
CipherListView,
|
||||
BitwardenClient,
|
||||
CipherView as SdkCipherView,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { UserId, OrganizationId } from "../../types/guid";
|
||||
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
|
||||
import { CipherType } from "../enums";
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
@@ -18,6 +23,67 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async encrypt(model: CipherView, userId: UserId): 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(sdkCipherView);
|
||||
|
||||
return {
|
||||
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
|
||||
encryptedFor: asUuid<UserId>(encryptionContext.encryptedFor),
|
||||
};
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to encrypt cipher: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async moveToOrganization(
|
||||
model: CipherView,
|
||||
organizationId: OrganizationId,
|
||||
userId: UserId,
|
||||
): 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 movedCipherView = ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.move_to_organization(sdkCipherView, asUuid(organizationId));
|
||||
|
||||
const encryptionContext = ref.value.vault().ciphers().encrypt(movedCipherView);
|
||||
|
||||
return {
|
||||
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
|
||||
encryptedFor: asUuid<UserId>(encryptionContext.encryptedFor),
|
||||
};
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to move cipher to organization: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async decrypt(cipher: Cipher, userId: UserId): Promise<CipherView> {
|
||||
return firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
@@ -51,11 +117,8 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
clientCipherView.login.fido2Credentials = fido2CredentialViews
|
||||
.map((f) => {
|
||||
const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!;
|
||||
|
||||
return {
|
||||
...view,
|
||||
keyValue: decryptedKeyValue,
|
||||
};
|
||||
view.keyValue = decryptedKeyValue;
|
||||
return view;
|
||||
})
|
||||
.filter((view): view is Fido2CredentialView => view !== undefined);
|
||||
}
|
||||
@@ -104,10 +167,8 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
clientCipherView.login.fido2Credentials = fido2CredentialViews
|
||||
.map((f) => {
|
||||
const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!;
|
||||
return {
|
||||
...view,
|
||||
keyValue: decryptedKeyValue,
|
||||
};
|
||||
view.keyValue = decryptedKeyValue;
|
||||
return view;
|
||||
})
|
||||
.filter((view): view is Fido2CredentialView => view !== undefined);
|
||||
}
|
||||
@@ -187,4 +248,25 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to convert a CipherView model to an SDK CipherView. Has special handling for Fido2 credentials
|
||||
* that need to be encrypted before being sent to the SDK.
|
||||
* @param model The CipherView model to convert
|
||||
* @param sdk An instance of SDK client
|
||||
* @private
|
||||
*/
|
||||
private toSdkCipherView(model: CipherView, sdk: BitwardenClient): SdkCipherView {
|
||||
let sdkCipherView = model.toSdkCipherView();
|
||||
|
||||
if (model.type === CipherType.Login && model.login?.hasFido2Credentials) {
|
||||
// Encrypt Fido2 credentials separately
|
||||
const fido2Credentials = model.login.fido2Credentials?.map((f) =>
|
||||
f.toSdkFido2CredentialFullView(),
|
||||
);
|
||||
sdkCipherView = sdk.vault().ciphers().set_fido2_credentials(sdkCipherView, fido2Credentials);
|
||||
}
|
||||
|
||||
return sdkCipherView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Generic container that constrains page content width.
|
||||
* bit-container is a minimally styled component that limits the max width of its content to the tailwind theme variable '4xl'. '4xl' is equal to the value of 56rem
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-container",
|
||||
|
||||
13
libs/components/src/container/container.mdx
Normal file
13
libs/components/src/container/container.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Meta, Primary, Title, Description } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./container.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { ContainerComponent } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
<Title />
|
||||
<Description />
|
||||
<Primary />
|
||||
34
libs/components/src/container/container.stories.ts
Normal file
34
libs/components/src/container/container.stories.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { ContainerComponent } from "./container.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Container",
|
||||
component: ContainerComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [ContainerComponent],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-47329&t=k6OTDDPZOTtypRqo-11",
|
||||
},
|
||||
},
|
||||
} as Meta<ContainerComponent>;
|
||||
|
||||
type Story = StoryObj<ContainerComponent>;
|
||||
|
||||
export const Container: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-container>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada felis nulla, dignissim suscipit metus posuere vel. Duis eget porttitor arcu. Praesent tempor sodales nisi ut rhoncus. Curabitur vel enim eget est elementum finibus nec vitae erat. Duis dapibus, purus varius porttitor facilisis, justo nibh scelerisque tortor, consequat eleifend augue mi et nisi. Pellentesque convallis eget sem vitae malesuada. In hac habitasse platea dictumst. Suspendisse vulputate, neque in feugiat ultricies, mi diam malesuada tellus, at ultrices nisi enim nec nunc. Integer sapien mi, facilisis sed ultrices eget, dapibus sed velit. Aenean convallis nulla id lacus mattis gravida.<p>
|
||||
|
||||
<p>Etiam quis ipsum in risus euismod sagittis ac vel lorem. Donec eget mollis augue. Maecenas vitae libero ornare felis sagittis consequat et nec urna. Integer velit sapien, mollis non magna consectetur, laoreet placerat risus. Pellentesque bibendum ante in diam commodo imperdiet. Donec ante ligula, interdum eu facilisis non, commodo eu dolor. Cras rutrum imperdiet tortor eget finibus. Donec fringilla vitae libero sed tincidunt. Quisque nulla quam, consectetur et dictum sit amet, ultrices quis tortor. Cras lacinia, lacus sed venenatis luctus, risus odio ultricies lacus, eu lacinia sapien nisl vel augue. Nunc fermentum ac nisl at dictum. Nulla gravida, odio ut pellentesque commodo, sapien urna ultrices enim, ut euismod odio nisi ac justo. Pellentesque auctor erat sit amet semper convallis. In finibus enim in lorem commodo, id pretium ligula finibus. Cras vehicula nisl eget gravida dapibus.</p>
|
||||
</bit-container>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -32,6 +32,7 @@ export class RadioInputComponent implements BitFormControlAbstraction {
|
||||
"tw-border-secondary-600",
|
||||
"tw-w-[1.12rem]",
|
||||
"tw-h-[1.12rem]",
|
||||
"!tw-p-[.125rem]",
|
||||
"tw-flex-none", // Flexbox fix for bit-form-control
|
||||
|
||||
"hover:tw-border-2",
|
||||
@@ -45,9 +46,8 @@ export class RadioInputComponent implements BitFormControlAbstraction {
|
||||
"before:tw-content-['']",
|
||||
"before:tw-transition",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:tw-rounded-full",
|
||||
"before:tw-inset-[2px]",
|
||||
"before:tw-size-full",
|
||||
|
||||
"disabled:tw-cursor-auto",
|
||||
"disabled:tw-bg-secondary-100",
|
||||
|
||||
@@ -279,7 +279,7 @@ export abstract class BaseImporter {
|
||||
result.collections = result.folders.map((f) => {
|
||||
const collection = new CollectionView();
|
||||
collection.name = f.name;
|
||||
collection.id = f.id;
|
||||
collection.id = f.id ?? undefined; // folder id may be null, which is not suitable for collections.
|
||||
return collection;
|
||||
});
|
||||
result.folderRelationships = [];
|
||||
@@ -320,12 +320,6 @@ export abstract class BaseImporter {
|
||||
} else {
|
||||
cipher.notes = cipher.notes.trim();
|
||||
}
|
||||
if (cipher.fields != null && cipher.fields.length === 0) {
|
||||
cipher.fields = null;
|
||||
}
|
||||
if (cipher.passwordHistory != null && cipher.passwordHistory.length === 0) {
|
||||
cipher.passwordHistory = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected processKvp(
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("Keeper CSV Importer", () => {
|
||||
expect(result != null).toBe(true);
|
||||
|
||||
const cipher = result.ciphers.shift();
|
||||
expect(cipher.fields).toBeNull();
|
||||
expect(cipher.fields.length).toBe(0);
|
||||
|
||||
const cipher2 = result.ciphers.shift();
|
||||
expect(cipher2.fields.length).toBe(2);
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("Keeper Json Importer", () => {
|
||||
expect(cipher3.login.username).toEqual("someUserName");
|
||||
expect(cipher3.login.password).toEqual("w4k4k1wergf$^&@#*%2");
|
||||
expect(cipher3.notes).toBeNull();
|
||||
expect(cipher3.fields).toBeNull();
|
||||
expect(cipher3.fields.length).toBe(0);
|
||||
expect(cipher3.login.uris.length).toEqual(1);
|
||||
const uriView3 = cipher3.login.uris.shift();
|
||||
expect(uriView3.uri).toEqual("https://example.com");
|
||||
|
||||
@@ -3,10 +3,13 @@ import {
|
||||
NEVER,
|
||||
Observable,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
forkJoin,
|
||||
map,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
@@ -83,6 +86,9 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
) {
|
||||
this.activeUserOrgKeys$ = this.stateProvider.activeUserId$.pipe(
|
||||
switchMap((userId) => (userId != null ? this.orgKeys$(userId) : NEVER)),
|
||||
filter((orgKeys) => orgKeys != null),
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
) as Observable<Record<OrganizationId, OrgKey>>;
|
||||
}
|
||||
|
||||
@@ -416,12 +422,9 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
}
|
||||
|
||||
async getOrgKey(orgId: OrganizationId): Promise<OrgKey | null> {
|
||||
const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$);
|
||||
if (activeUserId == null) {
|
||||
throw new Error("A user must be active to retrieve an org key");
|
||||
}
|
||||
const orgKeys = await firstValueFrom(this.orgKeys$(activeUserId));
|
||||
return orgKeys?.[orgId] ?? null;
|
||||
return await firstValueFrom(
|
||||
this.activeUserOrgKeys$.pipe(map((orgKeys) => orgKeys[orgId] ?? null)),
|
||||
);
|
||||
}
|
||||
|
||||
async makeDataEncKey<T extends OrgKey | UserKey>(
|
||||
|
||||
@@ -71,8 +71,11 @@ export class AddEditCustomFieldDialogComponent {
|
||||
if (this.data.disallowHiddenField && option.value === FieldType.Hidden) {
|
||||
return false;
|
||||
}
|
||||
// Filter out the Linked field type for Secure Notes
|
||||
if (this.data.cipherType === CipherType.SecureNote) {
|
||||
// Filter out the Linked field type for Secure Notes and SSH Keys
|
||||
if (
|
||||
this.data.cipherType === CipherType.SecureNote ||
|
||||
this.data.cipherType === CipherType.SshKey
|
||||
) {
|
||||
return option.value !== FieldType.Linked;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const createMockCollection = (
|
||||
organizationId: string,
|
||||
readOnly = false,
|
||||
canEdit = true,
|
||||
) => {
|
||||
): CollectionView => {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
@@ -42,6 +42,7 @@ const createMockCollection = (
|
||||
manage: true,
|
||||
assigned: true,
|
||||
type: CollectionTypes.DefaultUserCollection,
|
||||
isDefaultCollection: true,
|
||||
canEditItems: jest.fn().mockReturnValue(canEdit),
|
||||
canEdit: jest.fn(),
|
||||
canDelete: jest.fn(),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
@@ -31,21 +31,13 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
}
|
||||
|
||||
async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise<CipherView> {
|
||||
// Passing the original cipher is important here as it is responsible for appending to password history
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const encrypted = await this.cipherService.encrypt(
|
||||
cipher,
|
||||
activeUserId,
|
||||
null,
|
||||
null,
|
||||
config.originalCipher ?? null,
|
||||
);
|
||||
const encryptedCipher = encrypted.cipher;
|
||||
|
||||
let savedCipher: Cipher;
|
||||
|
||||
// Creating a new cipher
|
||||
if (cipher.id == null) {
|
||||
const encrypted = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
savedCipher = await this.cipherService.createWithServer(encrypted, config.admin);
|
||||
return await this.cipherService.decrypt(savedCipher, activeUserId);
|
||||
}
|
||||
@@ -61,16 +53,37 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
|
||||
// Call shareWithServer if the owner is changing from a user to an organization
|
||||
if (config.originalCipher.organizationId === null && cipher.organizationId != null) {
|
||||
// shareWithServer expects the cipher to have no organizationId set
|
||||
const organizationId = cipher.organizationId as OrganizationId;
|
||||
cipher.organizationId = null;
|
||||
|
||||
savedCipher = await this.cipherService.shareWithServer(
|
||||
cipher,
|
||||
cipher.organizationId,
|
||||
organizationId,
|
||||
cipher.collectionIds,
|
||||
activeUserId,
|
||||
config.originalCipher,
|
||||
);
|
||||
// If the collectionIds are the same, update the cipher normally
|
||||
} else if (isSetEqual(originalCollectionIds, newCollectionIds)) {
|
||||
const encrypted = await this.cipherService.encrypt(
|
||||
cipher,
|
||||
activeUserId,
|
||||
null,
|
||||
null,
|
||||
config.originalCipher,
|
||||
);
|
||||
savedCipher = await this.cipherService.updateWithServer(encrypted, config.admin);
|
||||
} else {
|
||||
const encrypted = await this.cipherService.encrypt(
|
||||
cipher,
|
||||
activeUserId,
|
||||
null,
|
||||
null,
|
||||
config.originalCipher,
|
||||
);
|
||||
const encryptedCipher = encrypted.cipher;
|
||||
|
||||
// Updating a cipher with collection changes is not supported with a single request currently
|
||||
// First update the cipher with the original collectionIds
|
||||
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
|
||||
|
||||
@@ -67,12 +67,12 @@
|
||||
</ng-container>
|
||||
|
||||
<!-- CUSTOM FIELDS -->
|
||||
<ng-container *ngIf="cipher.fields">
|
||||
<ng-container *ngIf="cipher.hasFields">
|
||||
<app-custom-fields-v2 [cipher]="cipher"> </app-custom-fields-v2>
|
||||
</ng-container>
|
||||
|
||||
<!-- ATTACHMENTS SECTION -->
|
||||
<ng-container *ngIf="cipher.attachments">
|
||||
<ng-container *ngIf="cipher.hasAttachments">
|
||||
<app-attachments-v2-view
|
||||
[emergencyAccessId]="emergencyAccessId"
|
||||
[cipher]="cipher"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
|
||||
import { ColorPasswordComponent, ColorPasswordModule, ItemModule } from "@bitwarden/components";
|
||||
|
||||
import { PasswordHistoryViewComponent } from "./password-history-view.component";
|
||||
@@ -54,8 +55,14 @@ describe("PasswordHistoryViewComponent", () => {
|
||||
});
|
||||
|
||||
describe("history", () => {
|
||||
const password1 = { password: "bad-password-1", lastUsedDate: new Date("09/13/2004") };
|
||||
const password2 = { password: "bad-password-2", lastUsedDate: new Date("02/01/2004") };
|
||||
const password1 = {
|
||||
password: "bad-password-1",
|
||||
lastUsedDate: new Date("09/13/2004"),
|
||||
} as PasswordHistoryView;
|
||||
const password2 = {
|
||||
password: "bad-password-2",
|
||||
lastUsedDate: new Date("02/01/2004"),
|
||||
} as PasswordHistoryView;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockCipher.passwordHistory = [password1, password2];
|
||||
|
||||
Reference in New Issue
Block a user