diff --git a/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts b/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts new file mode 100644 index 0000000000..4b5828ccf3 --- /dev/null +++ b/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts @@ -0,0 +1,41 @@ +import { Observable } from "rxjs"; + +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; + +import { CollectionData, Collection, CollectionView } from "../models"; + +export abstract class vNextCollectionService { + encryptedCollections$: (userId$: Observable) => Observable; + decryptedCollections$: (userId$: Observable) => Observable; + upsert: (collection: CollectionData | CollectionData[], userId: UserId) => Promise; + replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise; + /** + * Clear decrypted state without affecting encrypted state. + * Used for locking the vault. + */ + clearDecryptedState: (userId: UserId) => Promise; + /** + * Clear decrypted and encrypted state. + * Used for logging out. + */ + clear: (userId: string) => Promise; + delete: (id: string | string[], userId: UserId) => Promise; + encrypt: (model: CollectionView) => Promise; + /** + * @deprecated This method will soon be made private, use `decryptedCollections$` instead. + */ + decryptMany: ( + collections: Collection[], + orgKeys?: Record, + ) => Promise; + /** + * Transforms the input CollectionViews into TreeNodes + */ + getAllNested: (collections: CollectionView[]) => TreeNode[]; + /** + * Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id + */ + getNested: (collections: CollectionView[], id: string) => TreeNode; +} diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts new file mode 100644 index 0000000000..4ca60cba77 --- /dev/null +++ b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts @@ -0,0 +1,325 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of, ReplaySubject } from "rxjs"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { ContainerService } from "@bitwarden/common/platform/services/container.service"; +import { + FakeStateProvider, + makeEncString, + makeSymmetricCryptoKey, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { CollectionData } from "../models"; + +import { DefaultvNextCollectionService } from "./default-vnext-collection.service"; +import { ENCRYPTED_COLLECTION_DATA_KEY } from "./vnext-collection.state"; + +describe("DefaultvNextCollectionService", () => { + let keyService: MockProxy; + let encryptService: MockProxy; + let i18nService: MockProxy; + let stateProvider: FakeStateProvider; + + let userId: UserId; + + let cryptoKeys: ReplaySubject | null>; + + let collectionService: DefaultvNextCollectionService; + + beforeEach(() => { + userId = Utils.newGuid() as UserId; + + keyService = mock(); + encryptService = mock(); + i18nService = mock(); + stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + + cryptoKeys = new ReplaySubject(1); + keyService.orgKeys$.mockReturnValue(cryptoKeys); + + // Set up mock decryption + encryptService.decryptToUtf8 + .calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey)) + .mockImplementation((encString, key) => + Promise.resolve(encString.data.replace("ENC_", "DEC_")), + ); + + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + + // Arrange i18nService so that sorting algorithm doesn't throw + i18nService.collator = null; + + collectionService = new DefaultvNextCollectionService( + keyService, + encryptService, + i18nService, + stateProvider, + ); + }); + + afterEach(() => { + delete (window as any).bitwardenContainerService; + }); + + describe("decryptedCollections$", () => { + it("emits decrypted collections from state", async () => { + // Arrange test data + const org1 = Utils.newGuid() as OrganizationId; + const orgKey1 = makeSymmetricCryptoKey(64, 1); + const collection1 = collectionDataFactory(org1); + + const org2 = Utils.newGuid() as OrganizationId; + const orgKey2 = makeSymmetricCryptoKey(64, 2); + const collection2 = collectionDataFactory(org2); + + // Arrange dependencies + await setEncryptedState([collection1, collection2]); + cryptoKeys.next({ + [org1]: orgKey1, + [org2]: orgKey2, + }); + + const result = await firstValueFrom(collectionService.decryptedCollections$(of(userId))); + + // Assert emitted values + expect(result.length).toBe(2); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: "DEC_NAME_" + collection1.id, + }, + { + id: collection2.id, + name: "DEC_NAME_" + collection2.id, + }, + ]); + + // Assert that the correct org keys were used for each encrypted string + expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( + expect.objectContaining(new EncString(collection1.name)), + orgKey1, + ); + expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( + expect.objectContaining(new EncString(collection2.name)), + orgKey2, + ); + }); + + it("handles null collection state", async () => { + // Arrange dependencies + await setEncryptedState(null); + cryptoKeys.next({}); + + const encryptedCollections = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + + expect(encryptedCollections.length).toBe(0); + }); + }); + + describe("encryptedCollections$", () => { + it("emits encrypted collections from state", async () => { + // Arrange test data + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + + // Arrange dependencies + await setEncryptedState([collection1, collection2]); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + + expect(result.length).toBe(2); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: makeEncString("ENC_NAME_" + collection1.id), + }, + { + id: collection2.id, + name: makeEncString("ENC_NAME_" + collection2.id), + }, + ]); + }); + + it("handles null collection state", async () => { + await setEncryptedState(null); + + const decryptedCollections = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + expect(decryptedCollections.length).toBe(0); + }); + }); + + describe("upsert", () => { + it("upserts to existing collections", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + + await setEncryptedState([collection1, collection2]); + + const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, { + name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString, + }); + const newCollection3 = collectionDataFactory(); + + await collectionService.upsert([updatedCollection1, newCollection3], userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toBe(3); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: makeEncString("UPDATED_ENC_NAME_" + collection1.id), + }, + { + id: collection2.id, + name: makeEncString("ENC_NAME_" + collection2.id), + }, + { + id: newCollection3.id, + name: makeEncString("ENC_NAME_" + newCollection3.id), + }, + ]); + }); + + it("upserts to a null state", async () => { + const collection1 = collectionDataFactory(); + + await setEncryptedState(null); + + await collectionService.upsert(collection1, userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toBe(1); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: makeEncString("ENC_NAME_" + collection1.id), + }, + ]); + }); + }); + + describe("replace", () => { + it("replaces all collections", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + + const newCollection3 = collectionDataFactory(); + await collectionService.replace( + { + [newCollection3.id]: newCollection3, + }, + userId, + ); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toBe(1); + expect(result).toIncludeAllPartialMembers([ + { + id: newCollection3.id, + name: makeEncString("ENC_NAME_" + newCollection3.id), + }, + ]); + }); + }); + + it("clearDecryptedState", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + + await collectionService.clearDecryptedState(userId); + + // Encrypted state remains + const encryptedState = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + expect(encryptedState.length).toEqual(2); + + // Decrypted state is cleared + const decryptedState = await firstValueFrom( + collectionService.decryptedCollections$(of(userId)), + ); + expect(decryptedState.length).toEqual(0); + }); + + it("clear", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + cryptoKeys.next({}); + + await collectionService.clear(userId); + + // Encrypted state is cleared + const encryptedState = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + expect(encryptedState.length).toEqual(0); + + // Decrypted state is cleared + const decryptedState = await firstValueFrom( + collectionService.decryptedCollections$(of(userId)), + ); + expect(decryptedState.length).toEqual(0); + }); + + describe("delete", () => { + it("deletes a collection", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + await setEncryptedState([collection1, collection2]); + + await collectionService.delete(collection1.id, userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toEqual(1); + expect(result[0]).toMatchObject({ id: collection2.id }); + }); + + it("deletes several collections", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + const collection3 = collectionDataFactory(); + await setEncryptedState([collection1, collection2, collection3]); + + await collectionService.delete([collection1.id, collection3.id], userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toEqual(1); + expect(result[0]).toMatchObject({ id: collection2.id }); + }); + + it("handles null collections", async () => { + const collection1 = collectionDataFactory(); + await setEncryptedState(null); + + await collectionService.delete(collection1.id, userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toEqual(0); + }); + }); + + const setEncryptedState = (collectionData: CollectionData[] | null) => + stateProvider.setUserState( + ENCRYPTED_COLLECTION_DATA_KEY, + collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])), + userId, + ); +}); + +const collectionDataFactory = (orgId?: OrganizationId) => { + const collection = new CollectionData({} as any); + collection.id = Utils.newGuid() as CollectionId; + collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId); + collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString; + + return collection; +}; diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts new file mode 100644 index 0000000000..8ca1ab7fcf --- /dev/null +++ b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts @@ -0,0 +1,196 @@ +import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { StateProvider, DerivedState } from "@bitwarden/common/platform/state"; +import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { KeyService } from "@bitwarden/key-management"; + +import { vNextCollectionService } from "../abstractions/vnext-collection.service"; +import { Collection, CollectionData, CollectionView } from "../models"; + +import { + DECRYPTED_COLLECTION_DATA_KEY, + ENCRYPTED_COLLECTION_DATA_KEY, +} from "./vnext-collection.state"; + +const NestingDelimiter = "/"; + +export class DefaultvNextCollectionService implements vNextCollectionService { + constructor( + private keyService: KeyService, + private encryptService: EncryptService, + private i18nService: I18nService, + protected stateProvider: StateProvider, + ) {} + + encryptedCollections$(userId$: Observable) { + return userId$.pipe( + switchMap((userId) => this.encryptedState(userId).state$), + map((collections) => { + if (collections == null) { + return []; + } + + return Object.values(collections).map((c) => new Collection(c)); + }), + ); + } + + decryptedCollections$(userId$: Observable) { + return userId$.pipe( + switchMap((userId) => this.decryptedState(userId).state$), + map((collections) => collections ?? []), + ); + } + + async upsert(toUpdate: CollectionData | CollectionData[], userId: UserId): Promise { + if (toUpdate == null) { + return; + } + await this.encryptedState(userId).update((collections) => { + if (collections == null) { + collections = {}; + } + if (Array.isArray(toUpdate)) { + toUpdate.forEach((c) => { + collections[c.id] = c; + }); + } else { + collections[toUpdate.id] = toUpdate; + } + return collections; + }); + } + + async replace(collections: Record, userId: UserId): Promise { + await this.encryptedState(userId).update(() => collections); + } + + async clearDecryptedState(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + + await this.decryptedState(userId).forceValue(null); + } + + async clear(userId: UserId): Promise { + await this.encryptedState(userId).update(() => null); + // This will propagate from the encrypted state update, but by doing it explicitly + // the promise doesn't resolve until the update is complete. + await this.decryptedState(userId).forceValue(null); + } + + async delete(id: CollectionId | CollectionId[], userId: UserId): Promise { + await this.encryptedState(userId).update((collections) => { + if (collections == null) { + collections = {}; + } + if (typeof id === "string") { + delete collections[id]; + } else { + (id as CollectionId[]).forEach((i) => { + delete collections[i]; + }); + } + return collections; + }); + } + + async encrypt(model: CollectionView): Promise { + if (model.organizationId == null) { + throw new Error("Collection has no organization id."); + } + const key = await this.keyService.getOrgKey(model.organizationId); + if (key == null) { + throw new Error("No key for this collection's organization."); + } + const collection = new Collection(); + collection.id = model.id; + collection.organizationId = model.organizationId; + collection.readOnly = model.readOnly; + collection.externalId = model.externalId; + collection.name = await this.encryptService.encrypt(model.name, key); + return collection; + } + + // TODO: this should be private and orgKeys should be required. + // See https://bitwarden.atlassian.net/browse/PM-12375 + async decryptMany( + collections: Collection[], + orgKeys?: Record, + ): Promise { + if (collections == null || collections.length === 0) { + return []; + } + const decCollections: CollectionView[] = []; + + orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$); + + const promises: Promise[] = []; + collections.forEach((collection) => { + promises.push( + collection + .decrypt(orgKeys[collection.organizationId as OrganizationId]) + .then((c) => decCollections.push(c)), + ); + }); + await Promise.all(promises); + return decCollections.sort(Utils.getSortFunction(this.i18nService, "name")); + } + + getAllNested(collections: CollectionView[]): TreeNode[] { + const nodes: TreeNode[] = []; + collections.forEach((c) => { + const collectionCopy = new CollectionView(); + collectionCopy.id = c.id; + collectionCopy.organizationId = c.organizationId; + const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); + }); + return nodes; + } + + /** + * @deprecated August 30 2022: Moved to new Vault Filter Service + * Remove when Desktop and Browser are updated + */ + getNested(collections: CollectionView[], id: string): TreeNode { + const nestedCollections = this.getAllNested(collections); + return ServiceUtils.getTreeNodeObjectFromList( + nestedCollections, + id, + ) as TreeNode; + } + + /** + * @returns a SingleUserState for encrypted collection data. + */ + private encryptedState(userId: UserId) { + return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY); + } + + /** + * @returns a SingleUserState for decrypted collection data. + */ + private decryptedState(userId: UserId): DerivedState { + const encryptedCollectionsWithKeys = this.encryptedState(userId).combinedState$.pipe( + switchMap(([userId, collectionData]) => + combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]), + ), + ); + + return this.stateProvider.getDerived( + encryptedCollectionsWithKeys, + DECRYPTED_COLLECTION_DATA_KEY, + { + collectionService: this, + }, + ); + } +} diff --git a/libs/admin-console/src/common/collections/services/vnext-collection.state.ts b/libs/admin-console/src/common/collections/services/vnext-collection.state.ts new file mode 100644 index 0000000000..533308f3cc --- /dev/null +++ b/libs/admin-console/src/common/collections/services/vnext-collection.state.ts @@ -0,0 +1,37 @@ +import { Jsonify } from "type-fest"; + +import { + COLLECTION_DATA, + DeriveDefinition, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; + +import { vNextCollectionService } from "../abstractions/vnext-collection.service"; +import { Collection, CollectionData, CollectionView } from "../models"; + +export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record( + COLLECTION_DATA, + "collections", + { + deserializer: (jsonData: Jsonify) => CollectionData.fromJSON(jsonData), + clearOn: ["logout"], + }, +); + +export const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition< + [Record, Record], + CollectionView[], + { collectionService: vNextCollectionService } +>(COLLECTION_DATA, "decryptedCollections", { + deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)), + derive: async ([collections, orgKeys], { collectionService }) => { + if (collections == null) { + return []; + } + + const data = Object.values(collections).map((c) => new Collection(c)); + return await collectionService.decryptMany(data, orgKeys); + }, +}); diff --git a/libs/common/spec/matchers/index.ts b/libs/common/spec/matchers/index.ts index 235f54d775..44440be5b5 100644 --- a/libs/common/spec/matchers/index.ts +++ b/libs/common/spec/matchers/index.ts @@ -1,3 +1,5 @@ +import * as matchers from "jest-extended"; + import { toBeFulfilled, toBeResolved, toBeRejected } from "./promise-fulfilled"; import { toAlmostEqual } from "./to-almost-equal"; import { toEqualBuffer } from "./to-equal-buffer"; @@ -6,6 +8,9 @@ export * from "./to-equal-buffer"; export * from "./to-almost-equal"; export * from "./promise-fulfilled"; +// add all jest-extended matchers +expect.extend(matchers); + export function addCustomMatchers() { expect.extend({ toEqualBuffer: toEqualBuffer, diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index d372232937..1cead2aa62 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -46,8 +46,15 @@ export function makeStaticByteArray(length: number, start = 0) { return arr; } -export function makeSymmetricCryptoKey(length: 32 | 64 = 64) { - return new SymmetricCryptoKey(makeStaticByteArray(length)) as T; +/** + * Creates a symmetric crypto key for use in tests. This is deterministic, i.e. it will produce identical keys + * for identical argument values. Provide a unique value to the `seed` parameter to create different keys. + */ +export function makeSymmetricCryptoKey( + length: 32 | 64 = 64, + seed = 0, +) { + return new SymmetricCryptoKey(makeStaticByteArray(length, seed)) as T; } /** diff --git a/package-lock.json b/package-lock.json index 344cf7835e..f60ec73213 100644 --- a/package-lock.json +++ b/package-lock.json @@ -154,6 +154,7 @@ "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.0", "husky": "9.1.4", + "jest-extended": "^4.0.2", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.1.1", @@ -23930,6 +23931,27 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-extended": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz", + "integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==", + "dev": true, + "dependencies": { + "jest-diff": "^29.0.0", + "jest-get-type": "^29.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "jest": ">=27.2.5" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + } + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", diff --git a/package.json b/package.json index 75e91f6493..bf9c8b7673 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.0", "husky": "9.1.4", + "jest-extended": "^4.0.2", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.1.1",