diff --git a/libs/common/src/platform/models/domain/domain-base.spec.ts b/libs/common/src/platform/models/domain/domain-base.spec.ts new file mode 100644 index 0000000000..0bdee21e3c --- /dev/null +++ b/libs/common/src/platform/models/domain/domain-base.spec.ts @@ -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; + const key = makeSymmetricCryptoKey(64); + + beforeEach(() => { + encryptService = mock(); + }); + + 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(), + ); + + await domain["decryptObjWithKey"]( + // @ts-expect-error -- Clear is not of type EncString + ["encToString", "encString2", "plainText"], + makeSymmetricCryptoKey(64), + mock(), + ); + + const decrypted = await domain["decryptObjWithKey"]( + ["encToString"], + makeSymmetricCryptoKey(64), + mock(), + ); + + // @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"), + }); + }); + }); +}); diff --git a/libs/common/src/platform/models/domain/domain-base.ts b/libs/common/src/platform/models/domain/domain-base.ts index f7273d2435..1cfcfac02f 100644 --- a/libs/common/src/platform/models/domain/domain-base.ts +++ b/libs/common/src/platform/models/domain/domain-base.ts @@ -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 = ConditionalKeys, EncString>; +export type DecryptedObject< + TEncryptedObject, + TDecryptedKeys extends EncStringKeys, +> = Record & Omit; + // https://contributing.bitwarden.com/architecture/clients/data-model#domain export default class Domain { protected buildDomainModel( @@ -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, + >( + this: TThis, + encryptedProperties: TEncryptedKeys[], + key: SymmetricCryptoKey, + encryptService: EncryptService, + _: Constructor = this.constructor as Constructor, + ): Promise> { + 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; + } + + private async decryptProperty>( + 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, + }; + } } diff --git a/libs/common/src/platform/models/domain/enc-string.spec.ts b/libs/common/src/platform/models/domain/enc-string.spec.ts index 7583c37e1e..39d5883177 100644 --- a/libs/common/src/platform/models/domain/enc-string.spec.ts +++ b/libs/common/src/platform/models/domain/enc-string.spec.ts @@ -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"; @@ -113,6 +114,77 @@ describe("EncString", () => { }); }); + describe("decryptWithKey", () => { + const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data"); + + const cryptoService = mock(); + const encryptService = mock(); + 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"); diff --git a/libs/common/src/platform/models/domain/enc-string.ts b/libs/common/src/platform/models/domain/enc-string.ts index 1f4c8caf39..0b0a597acd 100644 --- a/libs/common/src/platform/models/domain/enc-string.ts +++ b/libs/common/src/platform/models/domain/enc-string.ts @@ -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; @@ -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 diff --git a/libs/common/src/vault/models/domain/folder.spec.ts b/libs/common/src/vault/models/domain/folder.spec.ts index 69134d19cf..785852b884 100644 --- a/libs/common/src/vault/models/domain/folder.spec.ts +++ b/libs/common/src/vault/models/domain/folder.spec.ts @@ -1,4 +1,7 @@ -import { mockEnc, mockFromJson } from "../../../../spec"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { makeEncString, makeSymmetricCryptoKey, mockEnc, mockFromJson } from "../../../../spec"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; import { FolderData } from "../../models/data/folder.data"; import { Folder } from "../../models/domain/folder"; @@ -60,4 +63,42 @@ describe("Folder", () => { expect(actual).toMatchObject(expected); }); }); + + describe("decryptWithKey", () => { + let encryptService: MockProxy; + const key = makeSymmetricCryptoKey(64); + + beforeEach(() => { + encryptService = mock(); + encryptService.decryptToUtf8.mockImplementation((value) => { + return Promise.resolve(value.data); + }); + }); + + it("decrypts the name", async () => { + const folder = new Folder(); + folder.name = makeEncString("encName"); + + const view = await folder.decryptWithKey(key, encryptService); + + expect(view).toEqual({ + name: "encName", + }); + }); + + it("assigns the folder id and revision date", async () => { + const folder = new Folder(); + folder.id = "id"; + folder.revisionDate = new Date("2022-01-31T12:00:00.000Z"); + + const view = await folder.decryptWithKey(key, encryptService); + + expect(view).toEqual( + expect.objectContaining({ + id: "id", + revisionDate: new Date("2022-01-31T12:00:00.000Z"), + }), + ); + }); + }); }); diff --git a/libs/common/src/vault/models/domain/folder.ts b/libs/common/src/vault/models/domain/folder.ts index 9505bad705..da9e9811d4 100644 --- a/libs/common/src/vault/models/domain/folder.ts +++ b/libs/common/src/vault/models/domain/folder.ts @@ -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; @@ -39,6 +47,17 @@ export class Folder extends Domain { ); } + async decryptWithKey( + key: SymmetricCryptoKey, + encryptService: EncryptService, + ): Promise { + const decrypted = await this.decryptObjWithKey(["name"], key, encryptService, Folder); + + const view = new FolderView(decrypted); + view.name = decrypted.name; + return view; + } + static fromJSON(obj: Jsonify) { const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); return Object.assign(new Folder(), obj, { name: EncString.fromJSON(obj.name), revisionDate }); diff --git a/libs/common/src/vault/models/view/folder.view.ts b/libs/common/src/vault/models/view/folder.view.ts index 7e5c51bc30..47659c2739 100644 --- a/libs/common/src/vault/models/view/folder.view.ts +++ b/libs/common/src/vault/models/view/folder.view.ts @@ -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"; @@ -9,7 +10,7 @@ export class FolderView implements View, ITreeNodeObject { name: string = null; revisionDate: Date = null; - constructor(f?: Folder) { + constructor(f?: Folder | DecryptedObject) { if (!f) { return; }