Skip to content

Commit

Permalink
[PS-11868] Require key for enc string decryption (#10981)
Browse files Browse the repository at this point in the history
* Specify enc string decryption key and service.

* Fix issue with identifying `this` type within extended classes

* Folder decryption example

* Test enc string changes

* Fix test name

* test decrypt with key
  • Loading branch information
MGibson1 authored Sep 30, 2024
1 parent cc9a726 commit a6b9088
Show file tree
Hide file tree
Showing 7 changed files with 358 additions and 4 deletions.
139 changes: 139 additions & 0 deletions libs/common/src/platform/models/domain/domain-base.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { mock, MockProxy } from "jest-mock-extended";

import { makeEncString, makeSymmetricCryptoKey } from "../../../../spec";
import { EncryptService } from "../../abstractions/encrypt.service";
import { Utils } from "../../misc/utils";

import Domain from "./domain-base";
import { EncString } from "./enc-string";

class TestDomain extends Domain {
plainText: string;
encToString: EncString;
encString2: EncString;
}

describe("DomainBase", () => {
let encryptService: MockProxy<EncryptService>;
const key = makeSymmetricCryptoKey(64);

beforeEach(() => {
encryptService = mock<EncryptService>();
});

function setUpCryptography() {
encryptService.encrypt.mockImplementation((value) => {
let data: string;
if (typeof value === "string") {
data = value;
} else {
data = Utils.fromBufferToUtf8(value);
}

return Promise.resolve(makeEncString(data));
});

encryptService.decryptToUtf8.mockImplementation((value) => {
return Promise.resolve(value.data);
});

encryptService.decryptToBytes.mockImplementation((value) => {
return Promise.resolve(value.dataBytes);
});
}

describe("decryptWithKey", () => {
it("domain property types are decryptable", async () => {
const domain = new TestDomain();

await domain["decryptObjWithKey"](
// @ts-expect-error -- clear is not of type EncString
["plainText"],
makeSymmetricCryptoKey(64),
mock<EncryptService>(),
);

await domain["decryptObjWithKey"](
// @ts-expect-error -- Clear is not of type EncString
["encToString", "encString2", "plainText"],
makeSymmetricCryptoKey(64),
mock<EncryptService>(),
);

const decrypted = await domain["decryptObjWithKey"](
["encToString"],
makeSymmetricCryptoKey(64),
mock<EncryptService>(),
);

// @ts-expect-error -- encString2 was not decrypted
decrypted as { encToString: string; encString2: string; plainText: string };

// encString2 was not decrypted, so it's still an EncString
decrypted as { encToString: string; encString2: EncString; plainText: string };
});

it("decrypts the encrypted properties", async () => {
setUpCryptography();

const domain = new TestDomain();

domain.encToString = await encryptService.encrypt("string", key);

const decrypted = await domain["decryptObjWithKey"](["encToString"], key, encryptService);

expect(decrypted).toEqual({
encToString: "string",
});
});

it("decrypts multiple encrypted properties", async () => {
setUpCryptography();

const domain = new TestDomain();

domain.encToString = await encryptService.encrypt("string", key);
domain.encString2 = await encryptService.encrypt("string2", key);

const decrypted = await domain["decryptObjWithKey"](
["encToString", "encString2"],
key,
encryptService,
);

expect(decrypted).toEqual({
encToString: "string",
encString2: "string2",
});
});

it("does not decrypt properties that are not encrypted", async () => {
const domain = new TestDomain();
domain.plainText = "clear";

const decrypted = await domain["decryptObjWithKey"]([], key, encryptService);

expect(decrypted).toEqual({
plainText: "clear",
});
});

it("does not decrypt properties that were not requested to be decrypted", async () => {
setUpCryptography();

const domain = new TestDomain();

domain.plainText = "clear";
domain.encToString = makeEncString("string");
domain.encString2 = makeEncString("string2");

const decrypted = await domain["decryptObjWithKey"]([], key, encryptService);

expect(decrypted).toEqual({
plainText: "clear",
encToString: makeEncString("string"),
encString2: makeEncString("string2"),
});
});
});
});
66 changes: 66 additions & 0 deletions libs/common/src/platform/models/domain/domain-base.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { ConditionalExcept, ConditionalKeys, Constructor } from "type-fest";

import { View } from "../../../models/view/view";
import { EncryptService } from "../../abstractions/encrypt.service";

import { EncString } from "./enc-string";
import { SymmetricCryptoKey } from "./symmetric-crypto-key";

// eslint-disable-next-line @typescript-eslint/ban-types
type EncStringKeys<T> = ConditionalKeys<ConditionalExcept<T, Function>, EncString>;
export type DecryptedObject<
TEncryptedObject,
TDecryptedKeys extends EncStringKeys<TEncryptedObject>,
> = Record<TDecryptedKeys, string> & Omit<TEncryptedObject, TDecryptedKeys>;

// https://contributing.bitwarden.com/architecture/clients/data-model#domain
export default class Domain {
protected buildDomainModel<D extends Domain>(
Expand Down Expand Up @@ -80,4 +90,60 @@ export default class Domain {
await Promise.all(promises);
return viewModel;
}

/**
* Decrypts the requested properties of the domain object with the provided key and encrypt service.
*
* If a property is null, the result will be null.
* @see {@link EncString.decryptWithKey} for more details on decryption behavior.
*
* @param encryptedProperties The properties to decrypt. Type restricted to EncString properties of the domain object.
* @param key The key to use for decryption.
* @param encryptService The encryption service to use for decryption.
* @param _ The constructor of the domain object. Used for type inference if the domain object is not automatically inferred.
* @returns An object with the requested properties decrypted and the rest of the domain object untouched.
*/
protected async decryptObjWithKey<
TThis extends Domain,
const TEncryptedKeys extends EncStringKeys<TThis>,
>(
this: TThis,
encryptedProperties: TEncryptedKeys[],
key: SymmetricCryptoKey,
encryptService: EncryptService,
_: Constructor<TThis> = this.constructor as Constructor<TThis>,
): Promise<DecryptedObject<TThis, TEncryptedKeys>> {
const promises = [];

for (const prop of encryptedProperties) {
const value = (this as any)[prop] as EncString;
promises.push(this.decryptProperty(prop, value, key, encryptService));
}

const decryptedObjects = await Promise.all(promises);
const decryptedObject = decryptedObjects.reduce(
(acc, obj) => {
return { ...acc, ...obj };
},
{ ...this },
);
return decryptedObject as DecryptedObject<TThis, TEncryptedKeys>;
}

private async decryptProperty<const TEncryptedKeys extends EncStringKeys<this>>(
propertyKey: TEncryptedKeys,
value: EncString,
key: SymmetricCryptoKey,
encryptService: EncryptService,
) {
let decrypted: string = null;
if (value) {
decrypted = await value.decryptWithKey(key, encryptService);
} else {
decrypted = null;
}
return {
[propertyKey]: decrypted,
};
}
}
74 changes: 73 additions & 1 deletion libs/common/src/platform/models/domain/enc-string.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { mock, MockProxy } from "jest-mock-extended";

import { makeStaticByteArray } from "../../../../spec";
import { makeEncString, makeStaticByteArray } from "../../../../spec";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { UserKey, OrgKey } from "../../../types/key";
import { CryptoService } from "../../abstractions/crypto.service";
import { EncryptionType } from "../../enums";
import { Utils } from "../../misc/utils";
import { ContainerService } from "../../services/container.service";

import { EncString } from "./enc-string";
Expand Down Expand Up @@ -113,6 +114,77 @@ describe("EncString", () => {
});
});

describe("decryptWithKey", () => {
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");

const cryptoService = mock<CryptoService>();
const encryptService = mock<EncryptService>();
encryptService.decryptToUtf8
.calledWith(encString, expect.anything())
.mockResolvedValue("decrypted");

function setupEncryption() {
encryptService.encrypt.mockImplementation(async (data, key) => {
if (typeof data === "string") {
return makeEncString(data);
} else {
return makeEncString(Utils.fromBufferToUtf8(data));
}
});
encryptService.decryptToUtf8.mockImplementation(async (encString, key) => {
return encString.data;
});
encryptService.decryptToBytes.mockImplementation(async (encString, key) => {
return encString.dataBytes;
});
}

beforeEach(() => {
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService,
);
});

it("decrypts using the provided key and encryptService", async () => {
setupEncryption();

const key = new SymmetricCryptoKey(makeStaticByteArray(32));
await encString.decryptWithKey(key, encryptService);

expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, key);
});

it("fails to decrypt when key is null", async () => {
const decrypted = await encString.decryptWithKey(null, encryptService);

expect(decrypted).toBe("[error: cannot decrypt]");
expect(encString.decryptedValue).toBe("[error: cannot decrypt]");
});

it("fails to decrypt when encryptService is null", async () => {
const decrypted = await encString.decryptWithKey(
new SymmetricCryptoKey(makeStaticByteArray(32)),
null,
);

expect(decrypted).toBe("[error: cannot decrypt]");
expect(encString.decryptedValue).toBe("[error: cannot decrypt]");
});

it("fails to decrypt when encryptService throws", async () => {
encryptService.decryptToUtf8.mockRejectedValue("error");

const decrypted = await encString.decryptWithKey(
new SymmetricCryptoKey(makeStaticByteArray(32)),
encryptService,
);

expect(decrypted).toBe("[error: cannot decrypt]");
expect(encString.decryptedValue).toBe("[error: cannot decrypt]");
});
});

describe("AesCbc256_B64", () => {
it("constructor", () => {
const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv");
Expand Down
18 changes: 17 additions & 1 deletion libs/common/src/platform/models/domain/enc-string.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Jsonify, Opaque } from "type-fest";

import { EncryptService } from "../../abstractions/encrypt.service";
import { EncryptionType, EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE } from "../../enums";
import { Encrypted } from "../../interfaces/encrypted";
import { Utils } from "../../misc/utils";

import { SymmetricCryptoKey } from "./symmetric-crypto-key";

export const DECRYPT_ERROR = "[error: cannot decrypt]";

export class EncString implements Encrypted {
encryptedString?: EncryptedString;
encryptionType?: EncryptionType;
Expand Down Expand Up @@ -167,11 +170,24 @@ export class EncString implements Encrypted {
const encryptService = Utils.getContainerService().getEncryptService();
this.decryptedValue = await encryptService.decryptToUtf8(this, key);
} catch (e) {
this.decryptedValue = "[error: cannot decrypt]";
this.decryptedValue = DECRYPT_ERROR;
}
return this.decryptedValue;
}

async decryptWithKey(key: SymmetricCryptoKey, encryptService: EncryptService) {
try {
if (key == null) {
throw new Error("No key to decrypt EncString");
}

this.decryptedValue = await encryptService.decryptToUtf8(this, key);
} catch (e) {
this.decryptedValue = DECRYPT_ERROR;
}

return this.decryptedValue;
}
private async getKeyForDecryption(orgId: string) {
const cryptoService = Utils.getContainerService().getCryptoService();
return orgId != null
Expand Down
Loading

0 comments on commit a6b9088

Please sign in to comment.