1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-22 16:29:09 +01:00

[PM-11869] Adjust CollectionService to be reactive to keys being available (#11144)

This commit is contained in:
Thomas Rittson 2024-10-03 08:06:41 +10:00 committed by GitHub
parent 0d877c4e77
commit c8d4b819bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 176 additions and 23 deletions

View File

@ -1,6 +1,7 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { CollectionId, UserId } from "../../types/guid"; import { CollectionId, OrganizationId, UserId } from "../../types/guid";
import { OrgKey } from "../../types/key";
import { CollectionData } from "../models/data/collection.data"; import { CollectionData } from "../models/data/collection.data";
import { Collection } from "../models/domain/collection"; import { Collection } from "../models/domain/collection";
import { TreeNode } from "../models/domain/tree-node"; import { TreeNode } from "../models/domain/tree-node";
@ -13,9 +14,13 @@ export abstract class CollectionService {
encrypt: (model: CollectionView) => Promise<Collection>; encrypt: (model: CollectionView) => Promise<Collection>;
decryptedCollectionViews$: (ids: CollectionId[]) => Observable<CollectionView[]>; decryptedCollectionViews$: (ids: CollectionId[]) => Observable<CollectionView[]>;
/** /**
* @deprecated This method will soon be made private, use `decryptedCollectionViews$` instead. * @deprecated This method will soon be made private
* See PM-12375
*/ */
decryptMany: (collections: Collection[]) => Promise<CollectionView[]>; decryptMany: (
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey>,
) => Promise<CollectionView[]>;
get: (id: string) => Promise<Collection>; get: (id: string) => Promise<Collection>;
getAll: () => Promise<Collection[]>; getAll: () => Promise<Collection[]>;
getAllDecrypted: () => Promise<CollectionView[]>; getAllDecrypted: () => Promise<CollectionView[]>;

View File

@ -0,0 +1,135 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import {
FakeStateProvider,
makeEncString,
makeSymmetricCryptoKey,
mockAccountServiceWith,
} from "../../../spec";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { Utils } from "../../platform/misc/utils";
import { EncString } from "../../platform/models/domain/enc-string";
import { ContainerService } from "../../platform/services/container.service";
import { CollectionId, OrganizationId, UserId } from "../../types/guid";
import { OrgKey } from "../../types/key";
import { CollectionData } from "../models/data/collection.data";
import { CollectionService, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.service";
describe("CollectionService", () => {
afterEach(() => {
delete (window as any).bitwardenContainerService;
});
describe("decryptedCollections$", () => {
it("emits decrypted collections from state", async () => {
// Arrange test collections
const org1 = Utils.newGuid() as OrganizationId;
const org2 = Utils.newGuid() as OrganizationId;
const collection1 = collectionDataFactory(org1);
const collection2 = collectionDataFactory(org2);
// Arrange state provider
const fakeStateProvider = mockStateProvider();
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, {
[collection1.id]: collection1,
[collection2.id]: collection2,
});
// Arrange cryptoService - orgKeys and mock decryption
const cryptoService = mockCryptoService();
cryptoService.orgKeys$.mockReturnValue(
of({
[org1]: makeSymmetricCryptoKey<OrgKey>(),
[org2]: makeSymmetricCryptoKey<OrgKey>(),
}),
);
const collectionService = new CollectionService(
cryptoService,
mock<EncryptService>(),
mockI18nService(),
fakeStateProvider,
);
const result = await firstValueFrom(collectionService.decryptedCollections$);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: collection1.id,
name: "DECRYPTED_STRING",
});
expect(result[1]).toMatchObject({
id: collection2.id,
name: "DECRYPTED_STRING",
});
});
it("handles null collection state", async () => {
// Arrange test collections
const org1 = Utils.newGuid() as OrganizationId;
const org2 = Utils.newGuid() as OrganizationId;
// Arrange state provider
const fakeStateProvider = mockStateProvider();
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, null);
// Arrange cryptoService - orgKeys and mock decryption
const cryptoService = mockCryptoService();
cryptoService.orgKeys$.mockReturnValue(
of({
[org1]: makeSymmetricCryptoKey<OrgKey>(),
[org2]: makeSymmetricCryptoKey<OrgKey>(),
}),
);
const collectionService = new CollectionService(
cryptoService,
mock<EncryptService>(),
mockI18nService(),
fakeStateProvider,
);
const decryptedCollections = await firstValueFrom(collectionService.decryptedCollections$);
expect(decryptedCollections.length).toBe(0);
const encryptedCollections = await firstValueFrom(collectionService.encryptedCollections$);
expect(encryptedCollections.length).toBe(0);
});
});
});
const mockI18nService = () => {
const i18nService = mock<I18nService>();
i18nService.collator = null; // this is a mock only, avoid use of this object
return i18nService;
};
const mockStateProvider = () => {
const userId = Utils.newGuid() as UserId;
return new FakeStateProvider(mockAccountServiceWith(userId));
};
const mockCryptoService = () => {
const cryptoService = mock<CryptoService>();
const encryptService = mock<EncryptService>();
encryptService.decryptToUtf8
.calledWith(expect.any(EncString), expect.anything())
.mockResolvedValue("DECRYPTED_STRING");
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
return cryptoService;
};
const collectionDataFactory = (orgId: OrganizationId) => {
const collection = new CollectionData({} as any);
collection.id = Utils.newGuid() as CollectionId;
collection.organizationId = orgId;
collection.name = makeEncString("ENC_STRING").encryptedString;
return collection;
};

View File

@ -1,4 +1,4 @@
import { firstValueFrom, map, Observable } from "rxjs"; import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
@ -15,6 +15,7 @@ import {
UserKeyDefinition, UserKeyDefinition,
} from "../../platform/state"; } from "../../platform/state";
import { CollectionId, OrganizationId, UserId } from "../../types/guid"; import { CollectionId, OrganizationId, UserId } from "../../types/guid";
import { OrgKey } from "../../types/key";
import { CollectionService as CollectionServiceAbstraction } from "../../vault/abstractions/collection.service"; import { CollectionService as CollectionServiceAbstraction } from "../../vault/abstractions/collection.service";
import { CollectionData } from "../models/data/collection.data"; import { CollectionData } from "../models/data/collection.data";
import { Collection } from "../models/domain/collection"; import { Collection } from "../models/domain/collection";
@ -22,7 +23,7 @@ import { TreeNode } from "../models/domain/tree-node";
import { CollectionView } from "../models/view/collection.view"; import { CollectionView } from "../models/view/collection.view";
import { ServiceUtils } from "../service-utils"; import { ServiceUtils } from "../service-utils";
const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>( export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
COLLECTION_DATA, COLLECTION_DATA,
"collections", "collections",
{ {
@ -31,19 +32,19 @@ const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, C
}, },
); );
const DECRYPTED_COLLECTION_DATA_KEY = DeriveDefinition.from< const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition<
Record<CollectionId, CollectionData>, [Record<CollectionId, CollectionData>, Record<OrganizationId, OrgKey>],
CollectionView[], CollectionView[],
{ collectionService: CollectionService } { collectionService: CollectionService }
>(ENCRYPTED_COLLECTION_DATA_KEY, { >(COLLECTION_DATA, "decryptedCollections", {
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)), deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
derive: async (collections: Record<CollectionId, CollectionData>, { collectionService }) => { derive: async ([collections, orgKeys], { collectionService }) => {
const data: Collection[] = []; if (collections == null) {
for (const id in collections ?? {}) { return [];
const collectionId = id as CollectionId;
data.push(new Collection(collections[collectionId]));
} }
return await collectionService.decryptMany(data);
const data = Object.values(collections).map((c) => new Collection(c));
return await collectionService.decryptMany(data, orgKeys);
}, },
}); });
@ -68,18 +69,25 @@ export class CollectionService implements CollectionServiceAbstraction {
protected stateProvider: StateProvider, protected stateProvider: StateProvider,
) { ) {
this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY); this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY);
this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe( this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe(
map((collections) => { map((collections) => {
const response: Collection[] = []; if (collections == null) {
for (const id in collections ?? {}) { return [];
response.push(new Collection(collections[id as CollectionId]));
} }
return response;
return Object.values(collections).map((c) => new Collection(c));
}), }),
); );
const encryptedCollectionsWithKeys = this.encryptedCollectionDataState.combinedState$.pipe(
switchMap(([userId, collectionData]) =>
combineLatest([of(collectionData), this.cryptoService.orgKeys$(userId)]),
),
);
this.decryptedCollectionDataState = this.stateProvider.getDerived( this.decryptedCollectionDataState = this.stateProvider.getDerived(
this.encryptedCollectionDataState.state$, encryptedCollectionsWithKeys,
DECRYPTED_COLLECTION_DATA_KEY, DECRYPTED_COLLECTION_DATA_KEY,
{ collectionService: this }, { collectionService: this },
); );
@ -108,19 +116,24 @@ export class CollectionService implements CollectionServiceAbstraction {
return collection; return collection;
} }
async decryptMany(collections: Collection[]): Promise<CollectionView[]> { // TODO: this should be private and orgKeys should be required.
if (collections == null) { // See https://bitwarden.atlassian.net/browse/PM-12375
async decryptMany(
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey>,
): Promise<CollectionView[]> {
if (collections == null || collections.length === 0) {
return []; return [];
} }
const decCollections: CollectionView[] = []; const decCollections: CollectionView[] = [];
const organizationKeys = await firstValueFrom(this.cryptoService.activeUserOrgKeys$); orgKeys ??= await firstValueFrom(this.cryptoService.activeUserOrgKeys$);
const promises: Promise<any>[] = []; const promises: Promise<any>[] = [];
collections.forEach((collection) => { collections.forEach((collection) => {
promises.push( promises.push(
collection collection
.decrypt(organizationKeys[collection.organizationId as OrganizationId]) .decrypt(orgKeys[collection.organizationId as OrganizationId])
.then((c) => decCollections.push(c)), .then((c) => decCollections.push(c)),
); );
}); });