Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PS-11868] Require key for enc string decryption #10981

Merged
merged 9 commits into from
Sep 30, 2024
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>,
Copy link
Member Author

@MGibson1 MGibson1 Sep 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type inferrer has a lot of trouble with this type on derived classes. It seems to always resolve to the parent class when using in the derived class. This pattern allows us to specify the specific Domain implementation by providing a constructor as an optional 5th argument. See the Folder changes for an example of its value.

): 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");
}
justindbaur marked this conversation as resolved.
Show resolved Hide resolved

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
Loading