1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 22:44:11 +00:00

Migrated folder service from using active user state to single user state

Added extra test cases for encrypted folder and decrypted folders

Updated derived state to use decrypt with key
This commit is contained in:
gbubemismith
2024-11-06 10:46:20 -05:00
parent 826037e163
commit df1caadf19
4 changed files with 192 additions and 150 deletions

View File

@@ -11,23 +11,22 @@ import { FolderWithIdRequest } from "../../models/request/folder-with-id.request
import { FolderView } from "../../models/view/folder.view";
export abstract class FolderService implements UserKeyRotationDataProvider<FolderWithIdRequest> {
folders$: Observable<Folder[]>;
folderViews$: Observable<FolderView[]>;
folders$: (userId$: Observable<UserId>) => Observable<Folder[]>;
folderViews$: (userId$: Observable<UserId>) => Observable<FolderView[]>;
clearCache: () => Promise<void>;
clearDecryptedFolderState: (userId: UserId) => Promise<void>;
encrypt: (model: FolderView, key: SymmetricCryptoKey) => Promise<Folder>;
get: (id: string) => Promise<Folder>;
getDecrypted$: (id: string) => Observable<FolderView | undefined>;
getAllFromState: () => Promise<Folder[]>;
get: (id: string, userId$: Observable<UserId>) => Promise<Folder>;
getDecrypted$: (id: string, userId$: Observable<UserId>) => Observable<FolderView | undefined>;
getAllFromState: (userId$: Observable<UserId>) => Promise<Folder[]>;
/**
* @deprecated Only use in CLI!
*/
getFromState: (id: string) => Promise<Folder>;
getFromState: (id: string, userId$: Observable<UserId>) => Promise<Folder>;
/**
* @deprecated Only use in CLI!
*/
getAllDecryptedFromState: () => Promise<FolderView[]>;
decryptFolders: (folders: Folder[]) => Promise<FolderView[]>;
getAllDecryptedFromState: (userId$: Observable<UserId>) => Promise<FolderView[]>;
/**
* Returns user folders re-encrypted with the new user key.
* @param originalUserKey the original user key
@@ -44,8 +43,8 @@ export abstract class FolderService implements UserKeyRotationDataProvider<Folde
}
export abstract class InternalFolderService extends FolderService {
upsert: (folder: FolderData | FolderData[]) => Promise<void>;
upsert: (folder: FolderData | FolderData[], userId: UserId) => Promise<void>;
replace: (folders: { [id: string]: FolderData }, userId: UserId) => Promise<void>;
clear: (userId?: string) => Promise<void>;
delete: (id: string | string[]) => Promise<any>;
clear: (userId: UserId) => Promise<void>;
delete: (id: string | string[], userId: UserId) => Promise<any>;
}

View File

@@ -1,10 +1,10 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
import { KeyService } from "../../../../../key-management/src/abstractions/key.service";
import { makeStaticByteArray } from "../../../../spec";
import { makeEncString } from "../../../../spec";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service";
import { FakeActiveUserState } from "../../../../spec/fake-state";
import { FakeSingleUserState } from "../../../../spec/fake-state";
import { FakeStateProvider } from "../../../../spec/fake-state-provider";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
@@ -29,8 +29,9 @@ describe("Folder Service", () => {
let stateProvider: FakeStateProvider;
const mockUserId = Utils.newGuid() as UserId;
const mockUserId$ = of(mockUserId);
let accountService: FakeAccountService;
let folderState: FakeActiveUserState<Record<string, FolderData>>;
let folderState: FakeSingleUserState<Record<string, FolderData>>;
beforeEach(() => {
keyService = mock<KeyService>();
@@ -42,11 +43,9 @@ describe("Folder Service", () => {
stateProvider = new FakeStateProvider(accountService);
i18nService.collator = new Intl.Collator("en");
i18nService.t.mockReturnValue("No Folder");
keyService.hasUserKey.mockResolvedValue(true);
keyService.getUserKeyWithLegacySupport.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(32)) as UserKey,
);
keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
encryptService.decryptToUtf8.mockResolvedValue("DEC");
folderService = new FolderService(
@@ -57,10 +56,53 @@ describe("Folder Service", () => {
stateProvider,
);
folderState = stateProvider.activeUser.getFake(FOLDER_ENCRYPTED_FOLDERS);
folderState = stateProvider.singleUser.getFake(mockUserId, FOLDER_ENCRYPTED_FOLDERS);
// Initial state
folderState.nextState({ "1": folderData("1", "test") });
folderState.nextState({ "1": folderData("1") });
});
describe("folders$", () => {
it("emits encrypted folders from state", async () => {
const folder1 = folderData("1");
const folder2 = folderData("2");
await stateProvider.setUserState(
FOLDER_ENCRYPTED_FOLDERS,
Object.fromEntries([folder1, folder2].map((f) => [f.id, f])),
mockUserId,
);
const result = await firstValueFrom(folderService.folders$(mockUserId$));
expect(result.length).toBe(2);
expect(result).toIncludeAllPartialMembers([
{ id: "1", name: makeEncString("ENC_STRING_1") },
{ id: "2", name: makeEncString("ENC_STRING_2") },
]);
});
});
describe("folderView$", () => {
it("emits decrypted folders from state", async () => {
const folder1 = folderData("1");
const folder2 = folderData("2");
await stateProvider.setUserState(
FOLDER_ENCRYPTED_FOLDERS,
Object.fromEntries([folder1, folder2].map((f) => [f.id, f])),
mockUserId,
);
const result = await firstValueFrom(folderService.folderViews$(mockUserId$));
expect(result.length).toBe(3);
expect(result).toIncludeAllPartialMembers([
{ id: "1", name: "DEC" },
{ id: "2", name: "DEC" },
{ name: "No Folder" },
]);
});
});
it("encrypt", async () => {
@@ -83,105 +125,77 @@ describe("Folder Service", () => {
describe("get", () => {
it("exists", async () => {
const result = await folderService.get("1");
const result = await folderService.get("1", mockUserId$);
expect(result).toEqual({
id: "1",
name: {
encryptedString: "test",
encryptionType: 0,
},
name: makeEncString("ENC_STRING_" + 1),
revisionDate: null,
});
});
it("not exists", async () => {
const result = await folderService.get("2");
const result = await folderService.get("2", mockUserId$);
expect(result).toBe(undefined);
});
});
it("upsert", async () => {
await folderService.upsert(folderData("2", "test 2"));
await folderService.upsert(folderData("2"), mockUserId);
expect(await firstValueFrom(folderService.folders$)).toEqual([
expect(await firstValueFrom(folderService.folders$(mockUserId$))).toEqual([
{
id: "1",
name: {
encryptedString: "test",
encryptionType: 0,
},
name: makeEncString("ENC_STRING_" + 1),
revisionDate: null,
},
{
id: "2",
name: {
encryptedString: "test 2",
encryptionType: 0,
},
name: makeEncString("ENC_STRING_" + 2),
revisionDate: null,
},
]);
});
it("replace", async () => {
await folderService.replace({ "2": folderData("2", "test 2") }, mockUserId);
await folderService.replace({ "4": folderData("4") }, mockUserId);
expect(await firstValueFrom(folderService.folders$)).toEqual([
expect(await firstValueFrom(folderService.folders$(mockUserId$))).toEqual([
{
id: "2",
name: {
encryptedString: "test 2",
encryptionType: 0,
},
id: "4",
name: makeEncString("ENC_STRING_" + 4),
revisionDate: null,
},
]);
});
it("delete", async () => {
await folderService.delete("1");
await folderService.delete("1", mockUserId);
expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
expect((await firstValueFrom(folderService.folders$(mockUserId$))).length).toBe(0);
});
it("clearCache", async () => {
await folderService.clearCache();
expect((await firstValueFrom(folderService.folders$)).length).toBe(1);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0);
});
describe("clear", () => {
describe("clearDecryptedFolderState", () => {
it("null userId", async () => {
await folderService.clear();
expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0);
await expect(folderService.clearDecryptedFolderState(null)).rejects.toThrow(
"User ID is required.",
);
});
/**
* TODO: Fix this test to address the problem where the fakes for the active user state is not
* updated as expected
*/
// it("matching userId", async () => {
// stateService.getUserId.mockResolvedValue("1");
// await folderService.clear("1" as UserId);
it("userId provided", async () => {
await folderService.clearDecryptedFolderState(mockUserId);
// expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
// });
expect((await firstValueFrom(folderService.folders$(mockUserId$))).length).toBe(1);
expect((await firstValueFrom(folderService.folderViews$(mockUserId$))).length).toBe(0);
});
});
/**
* TODO: Fix this test to address the problem where the fakes for the active user state is not
* updated as expected
*/
// it("mismatching userId", async () => {
// await folderService.clear("12" as UserId);
it("clear", async () => {
await folderService.clear(mockUserId);
// expect((await firstValueFrom(folderService.folders$)).length).toBe(1);
// expect((await firstValueFrom(folderService.folderViews$)).length).toBe(2);
// });
expect((await firstValueFrom(folderService.folders$(mockUserId$))).length).toBe(0);
expect((await firstValueFrom(folderService.folderViews$(mockUserId$))).length).toBe(0);
});
describe("getRotatedData", () => {
@@ -207,10 +221,10 @@ describe("Folder Service", () => {
});
});
function folderData(id: string, name: string) {
function folderData(id: string) {
const data = new FolderData({} as any);
data.id = id;
data.name = name;
data.name = makeEncString("ENC_STRING_" + data.id).encryptedString;
return data;
}

View File

@@ -1,12 +1,11 @@
import { Observable, firstValueFrom, map, shareReplay } from "rxjs";
import { Observable, firstValueFrom, map, of, shareReplay, switchMap } from "rxjs";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KeyService } from "../../../../../key-management/src/abstractions/key.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state";
import { DerivedState, StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { CipherService } from "../../../vault/abstractions/cipher.service";
@@ -19,35 +18,37 @@ import { FolderWithIdRequest } from "../../models/request/folder-with-id.request
import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state";
export class FolderService implements InternalFolderServiceAbstraction {
folders$: Observable<Folder[]>;
folderViews$: Observable<FolderView[]>;
private encryptedFoldersState: ActiveUserState<Record<string, FolderData>>;
private decryptedFoldersState: DerivedState<FolderView[]>;
constructor(
private keyService: KeyService,
private encryptService: EncryptService,
private i18nService: I18nService,
private cipherService: CipherService,
private stateProvider: StateProvider,
) {
this.encryptedFoldersState = this.stateProvider.getActive(FOLDER_ENCRYPTED_FOLDERS);
this.decryptedFoldersState = this.stateProvider.getDerived(
this.encryptedFoldersState.state$,
FOLDER_DECRYPTED_FOLDERS,
{ folderService: this, keyService: this.keyService },
);
) {}
this.folders$ = this.encryptedFoldersState.state$.pipe(
map((folderData) => Object.values(folderData).map((f) => new Folder(f))),
);
folders$(userId$: Observable<UserId>): Observable<Folder[]> {
return userId$.pipe(
switchMap((userId) => this.encryptedFoldersState(userId).state$),
map((folders) => {
if (folders == null) {
return [];
}
this.folderViews$ = this.decryptedFoldersState.state$;
return Object.values(folders).map((f) => new Folder(f));
}),
);
}
async clearCache(): Promise<void> {
await this.decryptedFoldersState.forceValue([]);
folderViews$(userId$: Observable<UserId>) {
return userId$.pipe(switchMap((userId) => this.decryptedFoldersState(userId).state$));
}
async clearDecryptedFolderState(userId: UserId): Promise<void> {
if (userId == null) {
throw new Error("User ID is required.");
}
await this.decryptedFoldersState(userId).forceValue([]);
}
// TODO: This should be moved to EncryptService or something
@@ -58,29 +59,29 @@ export class FolderService implements InternalFolderServiceAbstraction {
return folder;
}
async get(id: string): Promise<Folder> {
const folders = await firstValueFrom(this.folders$);
async get(id: string, userId$: Observable<UserId>): Promise<Folder> {
const folders = await firstValueFrom(this.folders$(userId$));
return folders.find((folder) => folder.id === id);
}
getDecrypted$(id: string): Observable<FolderView | undefined> {
return this.folderViews$.pipe(
getDecrypted$(id: string, userId$: Observable<UserId>): Observable<FolderView | undefined> {
return this.folderViews$(userId$).pipe(
map((folders) => folders.find((folder) => folder.id === id)),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
async getAllFromState(): Promise<Folder[]> {
return await firstValueFrom(this.folders$);
async getAllFromState(userId$: Observable<UserId>): Promise<Folder[]> {
return await firstValueFrom(this.folders$(userId$));
}
/**
* @deprecated For the CLI only
* @param id id of the folder
*/
async getFromState(id: string): Promise<Folder> {
const folder = await this.get(id);
async getFromState(id: string, userId$: Observable<UserId>): Promise<Folder> {
const folder = await this.get(id, userId$);
if (!folder) {
return null;
}
@@ -91,12 +92,12 @@ export class FolderService implements InternalFolderServiceAbstraction {
/**
* @deprecated Only use in CLI!
*/
async getAllDecryptedFromState(): Promise<FolderView[]> {
return await firstValueFrom(this.folderViews$);
async getAllDecryptedFromState(userId$: Observable<UserId>): Promise<FolderView[]> {
return await firstValueFrom(this.folderViews$(userId$));
}
async upsert(folderData: FolderData | FolderData[]): Promise<void> {
await this.encryptedFoldersState.update((folders) => {
async upsert(folderData: FolderData | FolderData[], userId: UserId): Promise<void> {
await this.encryptedFoldersState(userId).update((folders) => {
if (folders == null) {
folders = {};
}
@@ -125,17 +126,13 @@ export class FolderService implements InternalFolderServiceAbstraction {
});
}
async clear(userId?: UserId): Promise<void> {
if (userId == null) {
await this.encryptedFoldersState.update(() => ({}));
await this.decryptedFoldersState.forceValue([]);
} else {
await this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS).update(() => ({}));
}
async clear(userId: UserId): Promise<void> {
await this.encryptedFoldersState(userId).update(() => ({}));
await this.decryptedFoldersState(userId).forceValue([]);
}
async delete(id: string | string[]): Promise<any> {
await this.encryptedFoldersState.update((folders) => {
async delete(id: string | string[], userId: UserId): Promise<any> {
await this.encryptedFoldersState(userId).update((folders) => {
if (folders == null) {
return;
}
@@ -162,25 +159,11 @@ export class FolderService implements InternalFolderServiceAbstraction {
}
}
if (updates.length > 0) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.cipherService.upsert(updates.map((c) => c.toCipherData()));
await this.cipherService.upsert(updates.map((c) => c.toCipherData()));
}
}
}
async decryptFolders(folders: Folder[]) {
const decryptFolderPromises = folders.map((f) => f.decrypt());
const decryptedFolders = await Promise.all(decryptFolderPromises);
decryptedFolders.sort(Utils.getSortFunction(this.i18nService, "name"));
const noneFolder = new FolderView();
noneFolder.name = this.i18nService.t("noneFolder");
decryptedFolders.push(noneFolder);
return decryptedFolders;
}
async getRotatedData(
originalUserKey: UserKey,
newUserKey: UserKey,
@@ -191,7 +174,7 @@ export class FolderService implements InternalFolderServiceAbstraction {
}
let encryptedFolders: FolderWithIdRequest[] = [];
const folders = await firstValueFrom(this.folderViews$);
const folders = await firstValueFrom(this.folderViews$(of(userId)));
if (!folders) {
return encryptedFolders;
}
@@ -203,4 +186,27 @@ export class FolderService implements InternalFolderServiceAbstraction {
);
return encryptedFolders;
}
/**
* @returns a SingleUserState for the encrypted folders.
*/
private encryptedFoldersState(userId: UserId) {
return this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS);
}
/**
*
* @returns a SingleUserState for the decrypted folders.
*/
private decryptedFoldersState(userId: UserId): DerivedState<FolderView[]> {
return this.stateProvider.getDerived(
this.encryptedFoldersState(userId).combinedState$,
FOLDER_DECRYPTED_FOLDERS,
{
encryptService: this.encryptService,
i18nService: this.i18nService,
keyService: this.keyService,
},
);
}
}

View File

@@ -1,8 +1,13 @@
import { firstValueFrom } from "rxjs";
import { Jsonify } from "type-fest";
import { KeyService } from "../../../../../key-management/src/abstractions/key.service";
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 { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { DeriveDefinition, FOLDER_DISK, UserKeyDefinition } from "../../../platform/state";
import { FolderService } from "../../abstractions/folder/folder.service.abstraction";
import { FolderData } from "../../models/data/folder.data";
import { Folder } from "../../models/domain/folder";
import { FolderView } from "../../models/view/folder.view";
@@ -16,19 +21,37 @@ export const FOLDER_ENCRYPTED_FOLDERS = UserKeyDefinition.record<FolderData>(
},
);
export const FOLDER_DECRYPTED_FOLDERS = DeriveDefinition.from<
Record<string, FolderData>,
export const FOLDER_DECRYPTED_FOLDERS = new DeriveDefinition<
[UserId, Record<string, FolderData>],
FolderView[],
{ folderService: FolderService; keyService: KeyService }
>(FOLDER_ENCRYPTED_FOLDERS, {
{
encryptService: EncryptService;
i18nService: I18nService;
keyService: KeyService;
}
>(FOLDER_DISK, "decryptedFolders", {
deserializer: (obj) => obj.map((f) => FolderView.fromJSON(f)),
derive: async (from, { folderService, keyService }) => {
const folders = Object.values(from || {}).map((f) => new Folder(f));
if (await keyService.hasUserKey()) {
return await folderService.decryptFolders(folders);
} else {
derive: async ([userId, folderData], { encryptService, i18nService, keyService }) => {
if (!folderData) {
return [];
}
const folders = Object.values(folderData).map((f) => new Folder(f));
const userKey = await firstValueFrom(keyService.userKey$(userId));
if (!userKey) {
return [];
}
const decryptFolderPromises = folders.map((f) => f.decryptWithKey(userKey, encryptService));
const decryptedFolders = await Promise.all(decryptFolderPromises);
decryptedFolders.sort(Utils.getSortFunction(i18nService, "name"));
const noneFolder = new FolderView();
noneFolder.name = i18nService.t("noneFolder");
decryptedFolders.push(noneFolder);
return decryptedFolders;
},
});