From c90eb42eadb3204d35cc4732fe17b1a06c5eb265 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 26 Jul 2022 11:40:32 +1000 Subject: [PATCH] [EC-271] Refactor CryptoService - move symmetric encryption to EncryptService (#3042) * move decryptFromBytes, decryptToBytes, and encryptToBytes from CryptoService to EncryptService * leave redirects in CryptoService * combine encryptService decryptFromBytes and decryptToBytes methods * move parsing logic into EncArrayBuffer * add tests --- apps/cli/src/commands/download.command.ts | 5 +- .../services/nodeEnvSecureStorage.service.ts | 7 +- apps/web/src/app/send/access.component.ts | 5 +- .../src/components/attachments.component.ts | 9 +- libs/angular/src/components/view.component.ts | 5 +- .../common/spec/domain/encArrayBuffer.spec.ts | 76 ++++++++ .../spec/matchers/toEqualBuffer.spec.ts | 25 +++ libs/common/spec/matchers/toEqualBuffer.ts | 34 ++++ .../spec/services/cipher.service.spec.ts | 3 +- .../spec/services/crypto.service.spec.ts | 38 ++++ .../spec/services/encrypt.service.spec.ts | 163 ++++++++++++++++++ libs/common/spec/test.setup.ts | 22 +++ libs/common/spec/utils.ts | 4 +- .../abstractions/abstractEncrypt.service.ts | 10 +- .../common/src/abstractions/crypto.service.ts | 2 +- libs/common/src/interfaces/IEncrypted.ts | 8 + .../src/models/domain/encArrayBuffer.ts | 74 +++++++- libs/common/src/models/domain/encString.ts | 16 +- libs/common/src/services/cipher.service.ts | 4 +- libs/common/src/services/crypto.service.ts | 146 ++-------------- libs/common/src/services/encrypt.service.ts | 78 ++++++++- package-lock.json | 39 +++++ package.json | 3 +- 23 files changed, 615 insertions(+), 161 deletions(-) create mode 100644 libs/common/spec/domain/encArrayBuffer.spec.ts create mode 100644 libs/common/spec/matchers/toEqualBuffer.spec.ts create mode 100644 libs/common/spec/matchers/toEqualBuffer.ts create mode 100644 libs/common/spec/services/crypto.service.spec.ts create mode 100644 libs/common/spec/services/encrypt.service.spec.ts create mode 100644 libs/common/src/interfaces/IEncrypted.ts diff --git a/apps/cli/src/commands/download.command.ts b/apps/cli/src/commands/download.command.ts index ae3c35f00c..bcd5a831f4 100644 --- a/apps/cli/src/commands/download.command.ts +++ b/apps/cli/src/commands/download.command.ts @@ -1,6 +1,7 @@ import * as fet from "node-fetch"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; import { Response } from "@bitwarden/node/cli/models/response"; import { FileResponse } from "@bitwarden/node/cli/models/response/fileResponse"; @@ -24,8 +25,8 @@ export abstract class DownloadCommand { } try { - const buf = await response.arrayBuffer(); - const decBuf = await this.cryptoService.decryptFromBytes(buf, key); + const encBuf = await EncArrayBuffer.fromResponse(response); + const decBuf = await this.cryptoService.decryptFromBytes(encBuf, key); if (process.env.BW_SERVE === "true") { const res = new FileResponse(Buffer.from(decBuf), fileName); return Response.success(res); diff --git a/apps/cli/src/services/nodeEnvSecureStorage.service.ts b/apps/cli/src/services/nodeEnvSecureStorage.service.ts index 58337d06d7..dcec777347 100644 --- a/apps/cli/src/services/nodeEnvSecureStorage.service.ts +++ b/apps/cli/src/services/nodeEnvSecureStorage.service.ts @@ -2,6 +2,7 @@ import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { Utils } from "@bitwarden/common/misc/utils"; +import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; export class NodeEnvSecureStorageService implements AbstractStorageService { @@ -63,10 +64,8 @@ export class NodeEnvSecureStorageService implements AbstractStorageService { return null; } - const decValue = await this.cryptoService().decryptFromBytes( - Utils.fromB64ToArray(encValue).buffer, - sessionKey - ); + const encBuf = EncArrayBuffer.fromB64(encValue); + const decValue = await this.cryptoService().decryptFromBytes(encBuf, sessionKey); if (decValue == null) { this.logService.info("Failed to decrypt."); return null; diff --git a/apps/web/src/app/send/access.component.ts b/apps/web/src/app/send/access.component.ts index fca9a1deff..707a5bef63 100644 --- a/apps/web/src/app/send/access.component.ts +++ b/apps/web/src/app/send/access.component.ts @@ -10,6 +10,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti import { SEND_KDF_ITERATIONS } from "@bitwarden/common/enums/kdfType"; import { SendType } from "@bitwarden/common/enums/sendType"; import { Utils } from "@bitwarden/common/misc/utils"; +import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; import { SendAccess } from "@bitwarden/common/models/domain/sendAccess"; import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; import { SendAccessRequest } from "@bitwarden/common/models/request/sendAccessRequest"; @@ -109,8 +110,8 @@ export class AccessComponent implements OnInit { } try { - const buf = await response.arrayBuffer(); - const decBuf = await this.cryptoService.decryptFromBytes(buf, this.decKey); + const encBuf = await EncArrayBuffer.fromResponse(response); + const decBuf = await this.cryptoService.decryptFromBytes(encBuf, this.decKey); this.fileDownloadService.download({ fileName: this.send.file.fileName, blobData: decBuf, diff --git a/libs/angular/src/components/attachments.component.ts b/libs/angular/src/components/attachments.component.ts index 2419e01345..b535faaa80 100644 --- a/libs/angular/src/components/attachments.component.ts +++ b/libs/angular/src/components/attachments.component.ts @@ -9,6 +9,7 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; import { Cipher } from "@bitwarden/common/models/domain/cipher"; +import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; import { ErrorResponse } from "@bitwarden/common/models/response/errorResponse"; import { AttachmentView } from "@bitwarden/common/models/view/attachmentView"; import { CipherView } from "@bitwarden/common/models/view/cipherView"; @@ -167,12 +168,12 @@ export class AttachmentsComponent implements OnInit { } try { - const buf = await response.arrayBuffer(); + const encBuf = await EncArrayBuffer.fromResponse(response); const key = attachment.key != null ? attachment.key : await this.cryptoService.getOrgKey(this.cipher.organizationId); - const decBuf = await this.cryptoService.decryptFromBytes(buf, key); + const decBuf = await this.cryptoService.decryptFromBytes(encBuf, key); this.fileDownloadService.download({ fileName: attachment.fileName, blobData: decBuf, @@ -237,12 +238,12 @@ export class AttachmentsComponent implements OnInit { try { // 2. Resave - const buf = await response.arrayBuffer(); + const encBuf = await EncArrayBuffer.fromResponse(response); const key = attachment.key != null ? attachment.key : await this.cryptoService.getOrgKey(this.cipher.organizationId); - const decBuf = await this.cryptoService.decryptFromBytes(buf, key); + const decBuf = await this.cryptoService.decryptFromBytes(encBuf, key); this.cipherDomain = await this.cipherService.saveAttachmentRawWithServer( this.cipherDomain, attachment.fileName, diff --git a/libs/angular/src/components/view.component.ts b/libs/angular/src/components/view.component.ts index f3ee7c0fee..66dbae786c 100644 --- a/libs/angular/src/components/view.component.ts +++ b/libs/angular/src/components/view.component.ts @@ -27,6 +27,7 @@ import { CipherRepromptType } from "@bitwarden/common/enums/cipherRepromptType"; import { CipherType } from "@bitwarden/common/enums/cipherType"; import { EventType } from "@bitwarden/common/enums/eventType"; import { FieldType } from "@bitwarden/common/enums/fieldType"; +import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; import { ErrorResponse } from "@bitwarden/common/models/response/errorResponse"; import { AttachmentView } from "@bitwarden/common/models/view/attachmentView"; import { CipherView } from "@bitwarden/common/models/view/cipherView"; @@ -369,12 +370,12 @@ export class ViewComponent implements OnDestroy, OnInit { } try { - const buf = await response.arrayBuffer(); + const encBuf = await EncArrayBuffer.fromResponse(response); const key = attachment.key != null ? attachment.key : await this.cryptoService.getOrgKey(this.cipher.organizationId); - const decBuf = await this.cryptoService.decryptFromBytes(buf, key); + const decBuf = await this.cryptoService.decryptFromBytes(encBuf, key); this.fileDownloadService.download({ fileName: attachment.fileName, blobData: decBuf, diff --git a/libs/common/spec/domain/encArrayBuffer.spec.ts b/libs/common/spec/domain/encArrayBuffer.spec.ts new file mode 100644 index 0000000000..fb36393707 --- /dev/null +++ b/libs/common/spec/domain/encArrayBuffer.spec.ts @@ -0,0 +1,76 @@ +import { EncryptionType } from "@bitwarden/common/enums/encryptionType"; +import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; + +import { makeStaticByteArray } from "../utils"; + +describe("encArrayBuffer", () => { + describe("parses the buffer", () => { + test.each([ + [EncryptionType.AesCbc128_HmacSha256_B64, "AesCbc128_HmacSha256_B64"], + [EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"], + ])("with %c%s", (encType: EncryptionType) => { + const iv = makeStaticByteArray(16, 10); + const mac = makeStaticByteArray(32, 20); + // We use the minimum data length of 1 to test the boundary of valid lengths + const data = makeStaticByteArray(1, 100); + + const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength); + array.set([encType]); + array.set(iv, 1); + array.set(mac, 1 + iv.byteLength); + array.set(data, 1 + iv.byteLength + mac.byteLength); + + const actual = new EncArrayBuffer(array.buffer); + + expect(actual.encryptionType).toEqual(encType); + expect(actual.ivBytes).toEqualBuffer(iv); + expect(actual.macBytes).toEqualBuffer(mac); + expect(actual.dataBytes).toEqualBuffer(data); + }); + + it("with AesCbc256_B64", () => { + const encType = EncryptionType.AesCbc256_B64; + const iv = makeStaticByteArray(16, 10); + // We use the minimum data length of 1 to test the boundary of valid lengths + const data = makeStaticByteArray(1, 100); + + const array = new Uint8Array(1 + iv.byteLength + data.byteLength); + array.set([encType]); + array.set(iv, 1); + array.set(data, 1 + iv.byteLength); + + const actual = new EncArrayBuffer(array.buffer); + + expect(actual.encryptionType).toEqual(encType); + expect(actual.ivBytes).toEqualBuffer(iv); + expect(actual.dataBytes).toEqualBuffer(data); + expect(actual.macBytes).toBeNull(); + }); + }); + + describe("throws if the buffer has an invalid length", () => { + test.each([ + [EncryptionType.AesCbc128_HmacSha256_B64, 50, "AesCbc128_HmacSha256_B64"], + [EncryptionType.AesCbc256_HmacSha256_B64, 50, "AesCbc256_HmacSha256_B64"], + [EncryptionType.AesCbc256_B64, 18, "AesCbc256_B64"], + ])("with %c%c%s", (encType: EncryptionType, minLength: number) => { + // Generate invalid byte array + // Minus 1 to leave room for the encType, minus 1 to make it invalid + const invalidBytes = makeStaticByteArray(minLength - 2); + + const invalidArray = new Uint8Array(1 + invalidBytes.buffer.byteLength); + invalidArray.set([encType]); + invalidArray.set(invalidBytes, 1); + + expect(() => new EncArrayBuffer(invalidArray.buffer)).toThrow( + "Error parsing encrypted ArrayBuffer" + ); + }); + }); + + it("doesn't parse the buffer if the encryptionType is not supported", () => { + // Starting at 9 implicitly gives us an invalid encType + const bytes = makeStaticByteArray(50, 9); + expect(() => new EncArrayBuffer(bytes)).toThrow("Error parsing encrypted ArrayBuffer"); + }); +}); diff --git a/libs/common/spec/matchers/toEqualBuffer.spec.ts b/libs/common/spec/matchers/toEqualBuffer.spec.ts new file mode 100644 index 0000000000..ccf5742365 --- /dev/null +++ b/libs/common/spec/matchers/toEqualBuffer.spec.ts @@ -0,0 +1,25 @@ +import { makeStaticByteArray } from "../utils"; + +describe("toEqualBuffer custom matcher", () => { + it("matches identical ArrayBuffers", () => { + const array = makeStaticByteArray(10); + expect(array.buffer).toEqualBuffer(array.buffer); + }); + + it("matches an identical ArrayBuffer and Uint8Array", () => { + const array = makeStaticByteArray(10); + expect(array.buffer).toEqualBuffer(array); + }); + + it("doesn't match different ArrayBuffers", () => { + const array1 = makeStaticByteArray(10); + const array2 = makeStaticByteArray(10, 11); + expect(array1.buffer).not.toEqualBuffer(array2.buffer); + }); + + it("doesn't match a different ArrayBuffer and Uint8Array", () => { + const array1 = makeStaticByteArray(10); + const array2 = makeStaticByteArray(10, 11); + expect(array1.buffer).not.toEqualBuffer(array2); + }); +}); diff --git a/libs/common/spec/matchers/toEqualBuffer.ts b/libs/common/spec/matchers/toEqualBuffer.ts new file mode 100644 index 0000000000..fb8ae09492 --- /dev/null +++ b/libs/common/spec/matchers/toEqualBuffer.ts @@ -0,0 +1,34 @@ +/** + * The inbuilt toEqual() matcher will always return TRUE when provided with 2 ArrayBuffers. + * This is because an ArrayBuffer must be wrapped in a new Uint8Array to be accessible. + * This custom matcher will automatically instantiate a new Uint8Array on the recieved value + * (and optionally, the expected value) and then call toEqual() on the resulting Uint8Arrays. + */ +export const toEqualBuffer: jest.CustomMatcher = function ( + received: ArrayBuffer, + expected: Uint8Array | ArrayBuffer +) { + received = new Uint8Array(received); + + if (expected instanceof ArrayBuffer) { + expected = new Uint8Array(expected); + } + + if (this.equals(received, expected)) { + return { + message: () => `expected +${received} +not to match +${expected}`, + pass: true, + }; + } + + return { + message: () => `expected +${received} +to match +${expected}`, + pass: false, + }; +}; diff --git a/libs/common/spec/services/cipher.service.spec.ts b/libs/common/spec/services/cipher.service.spec.ts index 0242626e69..1a654bed6b 100644 --- a/libs/common/spec/services/cipher.service.spec.ts +++ b/libs/common/spec/services/cipher.service.spec.ts @@ -8,7 +8,6 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { Utils } from "@bitwarden/common/misc/utils"; import { Cipher } from "@bitwarden/common/models/domain/cipher"; import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; import { EncString } from "@bitwarden/common/models/domain/encString"; @@ -16,7 +15,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCry import { CipherService } from "@bitwarden/common/services/cipher.service"; const ENCRYPTED_TEXT = "This data has been encrypted"; -const ENCRYPTED_BYTES = new EncArrayBuffer(Utils.fromUtf8ToArray(ENCRYPTED_TEXT).buffer); +const ENCRYPTED_BYTES = Substitute.for(); describe("Cipher Service", () => { let cryptoService: SubstituteOf; diff --git a/libs/common/spec/services/crypto.service.spec.ts b/libs/common/spec/services/crypto.service.spec.ts new file mode 100644 index 0000000000..40db49dfa3 --- /dev/null +++ b/libs/common/spec/services/crypto.service.spec.ts @@ -0,0 +1,38 @@ +import { mock, mockReset } from "jest-mock-extended"; + +import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { CryptoService } from "@bitwarden/common/services/crypto.service"; + +describe("cryptoService", () => { + let cryptoService: CryptoService; + + const cryptoFunctionService = mock(); + const encryptService = mock(); + const platformUtilService = mock(); + const logService = mock(); + const stateService = mock(); + + beforeEach(() => { + mockReset(cryptoFunctionService); + mockReset(encryptService); + mockReset(platformUtilService); + mockReset(logService); + mockReset(stateService); + + cryptoService = new CryptoService( + cryptoFunctionService, + encryptService, + platformUtilService, + logService, + stateService + ); + }); + + it("instantiates", () => { + expect(cryptoService).not.toBeFalsy(); + }); +}); diff --git a/libs/common/spec/services/encrypt.service.spec.ts b/libs/common/spec/services/encrypt.service.spec.ts new file mode 100644 index 0000000000..5ec62e6590 --- /dev/null +++ b/libs/common/spec/services/encrypt.service.spec.ts @@ -0,0 +1,163 @@ +import { mockReset, mock, MockProxy } from "jest-mock-extended"; + +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { EncryptionType } from "@bitwarden/common/enums/encryptionType"; +import { EncArrayBuffer } from "@bitwarden/common/models/domain/encArrayBuffer"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; +import { EncryptService } from "@bitwarden/common/services/encrypt.service"; + +import { makeStaticByteArray } from "../utils"; + +describe("EncryptService", () => { + const cryptoFunctionService = mock(); + const logService = mock(); + + let encryptService: EncryptService; + + beforeEach(() => { + mockReset(cryptoFunctionService); + mockReset(logService); + + encryptService = new EncryptService(cryptoFunctionService, logService, true); + }); + + describe("encryptToBytes", () => { + const plainValue = makeStaticByteArray(16, 1); + const iv = makeStaticByteArray(16, 30); + const mac = makeStaticByteArray(32, 40); + const encryptedData = makeStaticByteArray(20, 50); + + it("throws if no key is provided", () => { + return expect(encryptService.encryptToBytes(plainValue, null)).rejects.toThrow( + "No encryption key" + ); + }); + + describe("encrypts data", () => { + beforeEach(() => { + cryptoFunctionService.randomBytes.calledWith(16).mockResolvedValueOnce(iv.buffer); + cryptoFunctionService.aesEncrypt.mockResolvedValue(encryptedData.buffer); + }); + + it("using a key which supports mac", async () => { + const key = mock(); + const encType = EncryptionType.AesCbc128_HmacSha256_B64; + key.encType = encType; + + key.macKey = makeStaticByteArray(16, 20); + + cryptoFunctionService.hmac.mockResolvedValue(mac.buffer); + + const actual = await encryptService.encryptToBytes(plainValue, key); + + expect(actual.encryptionType).toEqual(encType); + expect(actual.ivBytes).toEqualBuffer(iv); + expect(actual.macBytes).toEqualBuffer(mac); + expect(actual.dataBytes).toEqualBuffer(encryptedData); + expect(actual.buffer.byteLength).toEqual( + 1 + iv.byteLength + mac.byteLength + encryptedData.byteLength + ); + }); + + it("using a key which doesn't support mac", async () => { + const key = mock(); + const encType = EncryptionType.AesCbc256_B64; + key.encType = encType; + + key.macKey = null; + + const actual = await encryptService.encryptToBytes(plainValue, key); + + expect(cryptoFunctionService.hmac).not.toBeCalled(); + + expect(actual.encryptionType).toEqual(encType); + expect(actual.ivBytes).toEqualBuffer(iv); + expect(actual.macBytes).toBeNull(); + expect(actual.dataBytes).toEqualBuffer(encryptedData); + expect(actual.buffer.byteLength).toEqual(1 + iv.byteLength + encryptedData.byteLength); + }); + }); + }); + + describe("decryptToBytes", () => { + const encType = EncryptionType.AesCbc256_HmacSha256_B64; + const key = new SymmetricCryptoKey(makeStaticByteArray(64, 100), encType); + const computedMac = new Uint8Array(1).buffer; + 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("decrypts data with provided key", async () => { + const decryptedBytes = makeStaticByteArray(10, 200).buffer; + + cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1).buffer); + 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.encKey) + ); + + 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.macKey, + "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 encTypes don't match", async () => { + key.encType = EncryptionType.AesCbc256_B64; + cryptoFunctionService.compare.mockResolvedValue(true); + + const actual = await encryptService.decryptToBytes(encBuffer, key); + + expect(actual).toBeNull(); + expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/spec/test.setup.ts b/libs/common/spec/test.setup.ts index 9d9906aa24..b21b45290d 100644 --- a/libs/common/spec/test.setup.ts +++ b/libs/common/spec/test.setup.ts @@ -1,5 +1,27 @@ import { webcrypto } from "crypto"; +import { toEqualBuffer } from "./matchers/toEqualBuffer"; + Object.defineProperty(window, "crypto", { value: webcrypto, }); + +// Add custom matchers + +expect.extend({ + toEqualBuffer: toEqualBuffer, +}); + +interface CustomMatchers { + toEqualBuffer(expected: Uint8Array | ArrayBuffer): R; +} + +/* eslint-disable */ +declare global { + namespace jest { + interface Expect extends CustomMatchers {} + interface Matchers extends CustomMatchers {} + interface InverseAsymmetricMatchers extends CustomMatchers {} + } +} +/* eslint-enable */ diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index b01f8c1493..0ea16b4d72 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -28,10 +28,10 @@ export function mockEnc(s: string): EncString { return mock; } -export function makeStaticByteArray(length: number) { +export function makeStaticByteArray(length: number, start = 0) { const arr = new Uint8Array(length); for (let i = 0; i < length; i++) { - arr[i] = i; + arr[i] = start + i; } return arr; } diff --git a/libs/common/src/abstractions/abstractEncrypt.service.ts b/libs/common/src/abstractions/abstractEncrypt.service.ts index 8e1871074b..cad893765e 100644 --- a/libs/common/src/abstractions/abstractEncrypt.service.ts +++ b/libs/common/src/abstractions/abstractEncrypt.service.ts @@ -1,7 +1,15 @@ import { EncString } from "@bitwarden/common/models/domain/encString"; import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; +import { IEncrypted } from "../interfaces/IEncrypted"; +import { EncArrayBuffer } from "../models/domain/encArrayBuffer"; + export abstract class AbstractEncryptService { abstract encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise; - abstract decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise; + abstract encryptToBytes: ( + plainValue: ArrayBuffer, + key?: SymmetricCryptoKey + ) => Promise; + abstract decryptToUtf8: (encString: EncString, key: SymmetricCryptoKey) => Promise; + abstract decryptToBytes: (encThing: IEncrypted, key: SymmetricCryptoKey) => Promise; } diff --git a/libs/common/src/abstractions/crypto.service.ts b/libs/common/src/abstractions/crypto.service.ts index bc61ba7e19..de11e1f5a6 100644 --- a/libs/common/src/abstractions/crypto.service.ts +++ b/libs/common/src/abstractions/crypto.service.ts @@ -80,7 +80,7 @@ export abstract class CryptoService { rsaDecrypt: (encValue: string, privateKeyValue?: ArrayBuffer) => Promise; decryptToBytes: (encString: EncString, key?: SymmetricCryptoKey) => Promise; decryptToUtf8: (encString: EncString, key?: SymmetricCryptoKey) => Promise; - decryptFromBytes: (encBuf: ArrayBuffer, key: SymmetricCryptoKey) => Promise; + decryptFromBytes: (encBuffer: EncArrayBuffer, key: SymmetricCryptoKey) => Promise; randomNumber: (min: number, max: number) => Promise; validateKey: (key: SymmetricCryptoKey) => Promise; } diff --git a/libs/common/src/interfaces/IEncrypted.ts b/libs/common/src/interfaces/IEncrypted.ts new file mode 100644 index 0000000000..0775a1aced --- /dev/null +++ b/libs/common/src/interfaces/IEncrypted.ts @@ -0,0 +1,8 @@ +import { EncryptionType } from "../enums/encryptionType"; + +export interface IEncrypted { + encryptionType?: EncryptionType; + dataBytes: ArrayBuffer; + macBytes: ArrayBuffer; + ivBytes: ArrayBuffer; +} diff --git a/libs/common/src/models/domain/encArrayBuffer.ts b/libs/common/src/models/domain/encArrayBuffer.ts index 97f47c39ae..b20796cdf6 100644 --- a/libs/common/src/models/domain/encArrayBuffer.ts +++ b/libs/common/src/models/domain/encArrayBuffer.ts @@ -1,3 +1,73 @@ -export class EncArrayBuffer { - constructor(public buffer: ArrayBuffer) {} +import { EncryptionType } from "@bitwarden/common/enums/encryptionType"; +import { IEncrypted } from "@bitwarden/common/interfaces/IEncrypted"; +import { Utils } from "@bitwarden/common/misc/utils"; + +const ENC_TYPE_LENGTH = 1; +const IV_LENGTH = 16; +const MAC_LENGTH = 32; +const MIN_DATA_LENGTH = 1; + +export class EncArrayBuffer implements IEncrypted { + readonly encryptionType: EncryptionType = null; + readonly dataBytes: ArrayBuffer = null; + readonly ivBytes: ArrayBuffer = null; + readonly macBytes: ArrayBuffer = null; + + constructor(readonly buffer: ArrayBuffer) { + const encBytes = new Uint8Array(buffer); + const encType = encBytes[0]; + + switch (encType) { + case EncryptionType.AesCbc128_HmacSha256_B64: + case EncryptionType.AesCbc256_HmacSha256_B64: { + const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH + MIN_DATA_LENGTH; + if (encBytes.length < minimumLength) { + this.throwDecryptionError(); + } + + this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH).buffer; + this.macBytes = encBytes.slice( + ENC_TYPE_LENGTH + IV_LENGTH, + ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH + ).buffer; + this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH).buffer; + break; + } + case EncryptionType.AesCbc256_B64: { + const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MIN_DATA_LENGTH; + if (encBytes.length < minimumLength) { + this.throwDecryptionError(); + } + + this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH).buffer; + this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH).buffer; + break; + } + default: + this.throwDecryptionError(); + } + + this.encryptionType = encType; + } + + private throwDecryptionError() { + throw new Error( + "Error parsing encrypted ArrayBuffer: data is corrupted or has an invalid format." + ); + } + + static async fromResponse(response: { + arrayBuffer: () => Promise; + }): Promise { + const buffer = await response.arrayBuffer(); + if (buffer == null) { + throw new Error("Cannot create EncArrayBuffer from Response - Response is empty"); + } + return new EncArrayBuffer(buffer); + } + + static fromB64(b64: string) { + const buffer = Utils.fromB64ToArray(b64).buffer; + return new EncArrayBuffer(buffer); + } } diff --git a/libs/common/src/models/domain/encString.ts b/libs/common/src/models/domain/encString.ts index 74ae83707b..9c237a485b 100644 --- a/libs/common/src/models/domain/encString.ts +++ b/libs/common/src/models/domain/encString.ts @@ -1,10 +1,12 @@ +import { IEncrypted } from "@bitwarden/common/interfaces/IEncrypted"; + import { CryptoService } from "../../abstractions/crypto.service"; import { EncryptionType } from "../../enums/encryptionType"; import { Utils } from "../../misc/utils"; import { SymmetricCryptoKey } from "./symmetricCryptoKey"; -export class EncString { +export class EncString implements IEncrypted { encryptedString?: string; encryptionType?: EncryptionType; decryptedValue?: string; @@ -119,4 +121,16 @@ export class EncString { } return this.decryptedValue; } + + get ivBytes(): ArrayBuffer { + return this.iv == null ? null : Utils.fromB64ToArray(this.iv).buffer; + } + + get macBytes(): ArrayBuffer { + return this.mac == null ? null : Utils.fromB64ToArray(this.mac).buffer; + } + + get dataBytes(): ArrayBuffer { + return this.data == null ? null : Utils.fromB64ToArray(this.data).buffer; + } } diff --git a/libs/common/src/services/cipher.service.ts b/libs/common/src/services/cipher.service.ts index 7482828f67..c8e588616d 100644 --- a/libs/common/src/services/cipher.service.ts +++ b/libs/common/src/services/cipher.service.ts @@ -1058,8 +1058,8 @@ export class CipherService implements CipherServiceAbstraction { throw Error("Failed to download attachment: " + attachmentResponse.status.toString()); } - const buf = await attachmentResponse.arrayBuffer(); - const decBuf = await this.cryptoService.decryptFromBytes(buf, null); + const encBuf = await EncArrayBuffer.fromResponse(attachmentResponse); + const decBuf = await this.cryptoService.decryptFromBytes(encBuf, null); const key = await this.cryptoService.getOrgKey(organizationId); const encFileName = await this.cryptoService.encrypt(attachmentView.fileName, key); diff --git a/libs/common/src/services/crypto.service.ts b/libs/common/src/services/crypto.service.ts index 0d625295e4..9f78296f24 100644 --- a/libs/common/src/services/crypto.service.ts +++ b/libs/common/src/services/crypto.service.ts @@ -16,7 +16,6 @@ import { EEFLongWordList } from "../misc/wordlist"; import { EncryptedOrganizationKeyData } from "../models/data/encryptedOrganizationKeyData"; import { EncArrayBuffer } from "../models/domain/encArrayBuffer"; import { EncString } from "../models/domain/encString"; -import { EncryptedObject } from "../models/domain/encryptedObject"; import { BaseEncryptedOrganizationKey } from "../models/domain/encryptedOrganizationKey"; import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey"; import { ProfileOrganizationResponse } from "../models/response/profileOrganizationResponse"; @@ -513,33 +512,14 @@ export class CryptoService implements CryptoServiceAbstraction { return this.buildEncKey(key, encKey.key); } - /** - * @deprecated June 22 2022: This method has been moved to encryptService. - * All callers should use this service to grab the relevant key and use encryptService for encryption instead. - * This method will be removed once all existing code has been refactored to use encryptService. - */ async encrypt(plainValue: string | ArrayBuffer, key?: SymmetricCryptoKey): Promise { key = await this.getKeyForEncryption(key); - return await this.encryptService.encrypt(plainValue, key); } async encryptToBytes(plainValue: ArrayBuffer, key?: SymmetricCryptoKey): Promise { - const encValue = await this.aesEncrypt(plainValue, key); - let macLen = 0; - if (encValue.mac != null) { - macLen = encValue.mac.byteLength; - } - - const encBytes = new Uint8Array(1 + encValue.iv.byteLength + macLen + encValue.data.byteLength); - encBytes.set([encValue.key.encType]); - encBytes.set(new Uint8Array(encValue.iv), 1); - if (encValue.mac != null) { - 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.buffer); + key = await this.getKeyForEncryption(key); + return this.encryptService.encryptToBytes(plainValue, key); } async rsaEncrypt(data: ArrayBuffer, publicKey?: ArrayBuffer): Promise { @@ -608,15 +588,9 @@ export class CryptoService implements CryptoServiceAbstraction { } async decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise { - const iv = Utils.fromB64ToArray(encString.iv).buffer; - const data = Utils.fromB64ToArray(encString.data).buffer; - const mac = encString.mac ? Utils.fromB64ToArray(encString.mac).buffer : null; - const decipher = await this.aesDecryptToBytes(encString.encryptionType, data, iv, mac, key); - if (decipher == null) { - return null; - } - - return decipher; + const keyForEnc = await this.getKeyForEncryption(key); + const theKey = await this.resolveLegacyKey(encString.encryptionType, keyForEnc); + return this.encryptService.decryptToBytes(encString, theKey); } async decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise { @@ -625,49 +599,15 @@ export class CryptoService implements CryptoServiceAbstraction { return await this.encryptService.decryptToUtf8(encString, key); } - async decryptFromBytes(encBuf: ArrayBuffer, key: SymmetricCryptoKey): Promise { - if (encBuf == null) { - throw new Error("no encBuf."); + async decryptFromBytes(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise { + if (encBuffer == null) { + throw new Error("No buffer provided for decryption."); } - const encBytes = new Uint8Array(encBuf); - const encType = encBytes[0]; - let ctBytes: Uint8Array = null; - let ivBytes: Uint8Array = null; - let macBytes: Uint8Array = null; + key = await this.getKeyForEncryption(key); + key = await this.resolveLegacyKey(encBuffer.encryptionType, key); - switch (encType) { - case EncryptionType.AesCbc128_HmacSha256_B64: - case EncryptionType.AesCbc256_HmacSha256_B64: - if (encBytes.length <= 49) { - // 1 + 16 + 32 + ctLength - return null; - } - - ivBytes = encBytes.slice(1, 17); - macBytes = encBytes.slice(17, 49); - ctBytes = encBytes.slice(49); - break; - case EncryptionType.AesCbc256_B64: - if (encBytes.length <= 17) { - // 1 + 16 + ctLength - return null; - } - - ivBytes = encBytes.slice(1, 17); - ctBytes = encBytes.slice(17); - break; - default: - return null; - } - - return await this.aesDecryptToBytes( - encType, - ctBytes.buffer, - ivBytes.buffer, - macBytes != null ? macBytes.buffer : null, - key - ); + return this.encryptService.decryptToBytes(encBuffer, key); } // EFForg/OpenWireless @@ -722,7 +662,8 @@ export class CryptoService implements CryptoServiceAbstraction { return true; } - // Helpers + // ---HELPERS--- + protected async storeKey(key: SymmetricCryptoKey, userId?: string) { if (await this.shouldStoreKey(KeySuffixOptions.Auto, userId)) { await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId }); @@ -752,67 +693,6 @@ export class CryptoService implements CryptoServiceAbstraction { : await this.stateService.getCryptoMasterKeyBiometric({ userId: userId }); } - /** - * @deprecated June 22 2022: This method has been moved to encryptService. - * All callers should use encryptService instead. This method will be removed once all existing code has been refactored to use encryptService. - */ - private async aesEncrypt(data: ArrayBuffer, key: SymmetricCryptoKey): Promise { - const obj = new EncryptedObject(); - obj.key = await this.getKeyForEncryption(key); - obj.iv = await this.cryptoFunctionService.randomBytes(16); - obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, obj.key.encKey); - - if (obj.key.macKey != null) { - 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.buffer, obj.key.macKey, "sha256"); - } - - return obj; - } - - private async aesDecryptToBytes( - encType: EncryptionType, - data: ArrayBuffer, - iv: ArrayBuffer, - mac: ArrayBuffer, - key: SymmetricCryptoKey - ): Promise { - const keyForEnc = await this.getKeyForEncryption(key); - const theKey = await this.resolveLegacyKey(encType, keyForEnc); - - if (theKey.macKey != null && mac == null) { - return null; - } - - if (theKey.encType !== encType) { - return null; - } - - if (theKey.macKey != null && mac != null) { - const macData = new Uint8Array(iv.byteLength + data.byteLength); - macData.set(new Uint8Array(iv), 0); - macData.set(new Uint8Array(data), iv.byteLength); - const computedMac = await this.cryptoFunctionService.hmac( - macData.buffer, - theKey.macKey, - "sha256" - ); - if (computedMac === null) { - return null; - } - - const macsMatch = await this.cryptoFunctionService.compare(mac, computedMac); - if (!macsMatch) { - this.logService.error("mac failed."); - return null; - } - } - - return await this.cryptoFunctionService.aesDecrypt(data, iv, theKey.encKey); - } - private async getKeyForEncryption(key?: SymmetricCryptoKey): Promise { if (key != null) { return key; diff --git a/libs/common/src/services/encrypt.service.ts b/libs/common/src/services/encrypt.service.ts index e0187edfd4..ac1b06197c 100644 --- a/libs/common/src/services/encrypt.service.ts +++ b/libs/common/src/services/encrypt.service.ts @@ -6,6 +6,8 @@ import { EncryptedObject } from "@bitwarden/common/models/domain/encryptedObject import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; import { AbstractEncryptService } from "../abstractions/abstractEncrypt.service"; +import { IEncrypted } from "../interfaces/IEncrypted"; +import { EncArrayBuffer } from "../models/domain/encArrayBuffer"; export class EncryptService implements AbstractEncryptService { constructor( @@ -16,7 +18,7 @@ export class EncryptService implements AbstractEncryptService { async encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise { if (key == null) { - throw new Error("no encryption key provided."); + throw new Error("No encryption key provided."); } if (plainValue == null) { @@ -37,8 +39,34 @@ export class EncryptService implements AbstractEncryptService { return new EncString(encObj.key.encType, data, iv, mac); } + async encryptToBytes(plainValue: ArrayBuffer, key: SymmetricCryptoKey): Promise { + if (key == null) { + throw new Error("No encryption key provided."); + } + + const encValue = await this.aesEncrypt(plainValue, key); + let macLen = 0; + if (encValue.mac != null) { + macLen = encValue.mac.byteLength; + } + + const encBytes = new Uint8Array(1 + encValue.iv.byteLength + macLen + encValue.data.byteLength); + encBytes.set([encValue.key.encType]); + encBytes.set(new Uint8Array(encValue.iv), 1); + if (encValue.mac != null) { + 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.buffer); + } + async decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise { - if (key?.macKey != null && encString?.mac == null) { + if (key == null) { + throw new Error("No encryption key provided."); + } + + if (key.macKey != null && encString?.mac == null) { this.logService.error("mac required."); return null; } @@ -70,6 +98,52 @@ export class EncryptService implements AbstractEncryptService { return this.cryptoFunctionService.aesDecryptFast(fastParams); } + async decryptToBytes(encThing: IEncrypted, key: SymmetricCryptoKey): Promise { + if (key == null) { + throw new Error("No encryption key provided."); + } + + if (encThing == null) { + throw new Error("Nothing provided for decryption."); + } + + if (key.macKey != null && encThing.macBytes == null) { + return null; + } + + if (key.encType !== encThing.encryptionType) { + return null; + } + + if (key.macKey != null && encThing.macBytes != 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.buffer, + key.macKey, + "sha256" + ); + if (computedMac === null) { + return null; + } + + const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac); + if (!macsMatch) { + this.logMacFailed("mac failed."); + return null; + } + } + + const result = await this.cryptoFunctionService.aesDecrypt( + encThing.dataBytes, + encThing.ivBytes, + key.encKey + ); + + return result ?? null; + } + private async aesEncrypt(data: ArrayBuffer, key: SymmetricCryptoKey): Promise { const obj = new EncryptedObject(); obj.key = key; diff --git a/package-lock.json b/package-lock.json index e702831e8a..d40c1f0bf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -145,6 +145,7 @@ "husky": "^7.0.4", "jasmine-core": "^3.7.1", "jasmine-spec-reporter": "^7.0.0", + "jest-mock-extended": "^2.0.6", "jest-preset-angular": "^10.1.0", "lint-staged": "^12.4.1", "mini-css-extract-plugin": "^2.4.5", @@ -29686,6 +29687,19 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/jest-mock-extended": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-2.0.6.tgz", + "integrity": "sha512-KoDdjqwIp2phaOWB0hr4O+9HF7hIJx7O+Reefi3iGrNhUpzVkod9UozYTSanvbNvjFYIEH6noA2tIjc8IDpadw==", + "dev": true, + "dependencies": { + "ts-essentials": "^7.0.3" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0", + "typescript": "^3.0.0 || ^4.0.0" + } + }, "node_modules/jest-mock/node_modules/@jest/types": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", @@ -43038,6 +43052,15 @@ "node": ">=6.10" } }, + "node_modules/ts-essentials": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz", + "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", + "dev": true, + "peerDependencies": { + "typescript": ">=3.7.0" + } + }, "node_modules/ts-jest": { "version": "27.1.5", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.1.5.tgz", @@ -68593,6 +68616,15 @@ } } }, + "jest-mock-extended": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-2.0.6.tgz", + "integrity": "sha512-KoDdjqwIp2phaOWB0hr4O+9HF7hIJx7O+Reefi3iGrNhUpzVkod9UozYTSanvbNvjFYIEH6noA2tIjc8IDpadw==", + "dev": true, + "requires": { + "ts-essentials": "^7.0.3" + } + }, "jest-pnp-resolver": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", @@ -79023,6 +79055,13 @@ "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", "dev": true }, + "ts-essentials": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz", + "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", + "dev": true, + "requires": {} + }, "ts-jest": { "version": "27.1.5", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.1.5.tgz", diff --git a/package.json b/package.json index 779021bbc9..ee4ef82403 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "lint:fix": "eslint . --fix", "prettier": "prettier --write .", "test": "jest", - "test:watch": "jest --watch", + "test:watch": "jest --clearCache && jest --watch", "test:watch:all": "jest --watchAll", "docs:json": "compodoc -p ./tsconfig.json -e json -d .", "storybook": "npm run docs:json && start-storybook -p 6006", @@ -106,6 +106,7 @@ "husky": "^7.0.4", "jasmine-core": "^3.7.1", "jasmine-spec-reporter": "^7.0.0", + "jest-mock-extended": "^2.0.6", "jest-preset-angular": "^10.1.0", "lint-staged": "^12.4.1", "mini-css-extract-plugin": "^2.4.5",