1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-21 03:43:58 +00:00

Merge branch 'main' into km/new-mp-service-api

This commit is contained in:
Bernd Schoolmann
2025-07-22 15:09:47 +02:00
committed by GitHub
327 changed files with 16304 additions and 3321 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 {}
}

View File

@@ -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.");

View File

@@ -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));
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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 {}
}

View File

@@ -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 }),
);
});
});
});

View File

@@ -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 {}
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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

View File

@@ -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) {

View 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.
*

View File

@@ -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[],

View File

@@ -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;
}
}

View File

@@ -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,
};
}

View File

@@ -93,6 +93,7 @@ describe("Attachment", () => {
sizeName: "1.1 KB",
fileName: "fileName",
key: expect.any(SymmetricCryptoKey),
encryptedKey: attachment.key,
});
});

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -16,6 +16,6 @@ export class TreeNode<T extends ITreeNodeObject> {
}
export interface ITreeNodeObject {
id: string;
name: string;
id: string | undefined;
name: string | undefined;
}

View File

@@ -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;
}
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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(),
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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>,

View File

@@ -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
};
}
}

View File

@@ -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",
});
});
});
});

View File

@@ -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(),
};
}
}

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
);
});
});
});

View File

@@ -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);

View File

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

View File

@@ -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;
}
}