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("validates the view matches the domain", 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,
};
}
}
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 @@
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");

Check warning on line 181 in libs/common/src/platform/models/domain/enc-string.ts

View check run for this annotation

Codecov / codecov/patch

libs/common/src/platform/models/domain/enc-string.ts#L181

Added line #L181 was not covered by tests
}

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

Check warning on line 186 in libs/common/src/platform/models/domain/enc-string.ts

View check run for this annotation

Codecov / codecov/patch

libs/common/src/platform/models/domain/enc-string.ts#L186

Added line #L186 was not covered by tests
}

return this.decryptedValue;
}
private async getKeyForDecryption(orgId: string) {
const cryptoService = Utils.getContainerService().getCryptoService();
return orgId != null
Expand Down
19 changes: 19 additions & 0 deletions libs/common/src/vault/models/domain/folder.ts
Copy link
Member Author

Choose a reason for hiding this comment

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

These folder updates are example usages. We can remove from this PR to limit scope.

Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { Jsonify } from "type-fest";

import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import Domain from "../../../platform/models/domain/domain-base";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { FolderData } from "../data/folder.data";
import { FolderView } from "../view/folder.view";

export class Test extends Domain {
id: string;
name: EncString;
revisionDate: Date;
}

export class Folder extends Domain {
id: string;
name: EncString;
Expand Down Expand Up @@ -39,6 +47,17 @@
);
}

async decryptWithKey(
key: SymmetricCryptoKey,
encryptService: EncryptService,
): Promise<FolderView> {
const decrypted = await this.decryptObjWithKey(["name"], key, encryptService, Folder);

Check warning on line 54 in libs/common/src/vault/models/domain/folder.ts

View check run for this annotation

Codecov / codecov/patch

libs/common/src/vault/models/domain/folder.ts#L54

Added line #L54 was not covered by tests

const view = new FolderView(decrypted);
view.name = decrypted.name;
return view;

Check warning on line 58 in libs/common/src/vault/models/domain/folder.ts

View check run for this annotation

Codecov / codecov/patch

libs/common/src/vault/models/domain/folder.ts#L56-L58

Added lines #L56 - L58 were not covered by tests
}

static fromJSON(obj: Jsonify<Folder>) {
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
return Object.assign(new Folder(), obj, { name: EncString.fromJSON(obj.name), revisionDate });
Expand Down
3 changes: 2 additions & 1 deletion libs/common/src/vault/models/view/folder.view.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Jsonify } from "type-fest";

import { View } from "../../../models/view/view";
import { DecryptedObject } from "../../../platform/models/domain/domain-base";
import { Folder } from "../domain/folder";
import { ITreeNodeObject } from "../domain/tree-node";

Expand All @@ -9,7 +10,7 @@ export class FolderView implements View, ITreeNodeObject {
name: string = null;
revisionDate: Date = null;

constructor(f?: Folder) {
constructor(f?: Folder | DecryptedObject<Folder, "name">) {
if (!f) {
return;
}
Expand Down
Loading