mirror of
https://github.com/bitwarden/browser
synced 2025-12-20 10:13:31 +00:00
[PM-6789] finish key rotation distribution and fix legacy user (#9498)
* finish key rotation distribution and fix legacy user * add ticket to TODO * PR feedback: docs and renaming * fix webauthn tests * add test for send service * add await to test
This commit is contained in:
@@ -1,19 +1,22 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||
import { LocalData } from "@bitwarden/common/vault/models/data/local.data";
|
||||
|
||||
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
|
||||
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
import { Field } from "../models/domain/field";
|
||||
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { FieldView } from "../models/view/field.view";
|
||||
import { AddEditCipherInfo } from "../types/add-edit-cipher-info";
|
||||
|
||||
export abstract class CipherService {
|
||||
export abstract class CipherService implements UserKeyRotationDataProvider<CipherWithIdRequest> {
|
||||
cipherViews$: Observable<Record<CipherId, CipherView>>;
|
||||
ciphers$: Observable<Record<CipherId, CipherData>>;
|
||||
localData$: Observable<Record<CipherId, LocalData>>;
|
||||
@@ -146,4 +149,17 @@ export abstract class CipherService {
|
||||
restoreManyWithServer: (ids: string[], orgId?: string) => Promise<void>;
|
||||
getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise<any>;
|
||||
setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise<void>;
|
||||
/**
|
||||
* Returns user ciphers re-encrypted with the new user key.
|
||||
* @param originalUserKey the original user key
|
||||
* @param newUserKey the new user key
|
||||
* @param userId the user id
|
||||
* @throws Error if new user key is null
|
||||
* @returns a list of user ciphers that have been re-encrypted with the new user key
|
||||
*/
|
||||
getRotatedData: (
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
) => Promise<CipherWithIdRequest[]>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserKeyRotationDataProvider } from "@bitwarden/auth/common";
|
||||
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { FolderData } from "../../models/data/folder.data";
|
||||
import { Folder } from "../../models/domain/folder";
|
||||
import { FolderWithIdRequest } from "../../models/request/folder-with-id.request";
|
||||
import { FolderView } from "../../models/view/folder.view";
|
||||
|
||||
export abstract class FolderService {
|
||||
export abstract class FolderService implements UserKeyRotationDataProvider<FolderWithIdRequest> {
|
||||
folders$: Observable<Folder[]>;
|
||||
folderViews$: Observable<FolderView[]>;
|
||||
|
||||
@@ -22,6 +27,19 @@ export abstract class FolderService {
|
||||
*/
|
||||
getAllDecryptedFromState: () => Promise<FolderView[]>;
|
||||
decryptFolders: (folders: Folder[]) => Promise<FolderView[]>;
|
||||
/**
|
||||
* Returns user folders re-encrypted with the new user key.
|
||||
* @param originalUserKey the original user key
|
||||
* @param newUserKey the new user key
|
||||
* @param userId the user id
|
||||
* @throws Error if new user key is null
|
||||
* @returns a list of user folders that have been re-encrypted with the new user key
|
||||
*/
|
||||
getRotatedData: (
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
) => Promise<FolderWithIdRequest[]>;
|
||||
}
|
||||
|
||||
export abstract class InternalFolderService extends FolderService {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
@@ -10,7 +10,7 @@ import { AutofillSettingsService } from "../../autofill/services/autofill-settin
|
||||
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
|
||||
import { UriMatchStrategy } from "../../models/domain/domain-service";
|
||||
import { ConfigService } from "../../platform/abstractions/config/config.service";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { CipherDecryptionKeys, CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
@@ -19,8 +19,8 @@ import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "../../platform/services/container.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherKey, OrgKey } from "../../types/key";
|
||||
import { CipherId, UserId } from "../../types/guid";
|
||||
import { CipherKey, OrgKey, UserKey } from "../../types/key";
|
||||
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FieldType } from "../enums";
|
||||
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
|
||||
@@ -331,6 +331,64 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedData", () => {
|
||||
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
let decryptedCiphers: BehaviorSubject<Record<CipherId, CipherView>>;
|
||||
let encryptedKey: EncString;
|
||||
|
||||
beforeEach(() => {
|
||||
setEncryptionKeyFlag(true);
|
||||
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
|
||||
|
||||
searchService.indexedEntityId$ = of(null);
|
||||
stateService.getUserId.mockResolvedValue(mockUserId);
|
||||
|
||||
const keys = {
|
||||
userKey: originalUserKey,
|
||||
} as CipherDecryptionKeys;
|
||||
cryptoService.cipherDecryptionKeys$.mockReturnValue(of(keys));
|
||||
|
||||
const cipher1 = new CipherView(cipherObj);
|
||||
cipher1.id = "Cipher 1";
|
||||
const cipher2 = new CipherView(cipherObj);
|
||||
cipher2.id = "Cipher 2";
|
||||
|
||||
decryptedCiphers = new BehaviorSubject({
|
||||
Cipher1: cipher1,
|
||||
Cipher2: cipher2,
|
||||
});
|
||||
cipherService.cipherViews$ = decryptedCiphers;
|
||||
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32));
|
||||
encryptedKey = new EncString("Re-encrypted Cipher Key");
|
||||
encryptService.encrypt.mockResolvedValue(encryptedKey);
|
||||
|
||||
cryptoService.makeCipherKey.mockResolvedValue(
|
||||
new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns re-encrypted user ciphers", async () => {
|
||||
const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||
|
||||
expect(result[0]).toMatchObject({ id: "Cipher 1", key: "Re-encrypted Cipher Key" });
|
||||
expect(result[1]).toMatchObject({ id: "Cipher 2", key: "Re-encrypted Cipher Key" });
|
||||
});
|
||||
|
||||
it("throws if the original user key is null", async () => {
|
||||
await expect(cipherService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow(
|
||||
"Original user key is required to rotate ciphers",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if the new user key is null", async () => {
|
||||
await expect(cipherService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
|
||||
"New user key is required to rotate ciphers",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setEncryptionKeyFlag(value: boolean) {
|
||||
|
||||
@@ -56,6 +56,7 @@ import { CipherCollectionsRequest } from "../models/request/cipher-collections.r
|
||||
import { CipherCreateRequest } from "../models/request/cipher-create.request";
|
||||
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
|
||||
import { CipherShareRequest } from "../models/request/cipher-share.request";
|
||||
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
|
||||
import { CipherRequest } from "../models/request/cipher.request";
|
||||
import { CipherResponse } from "../models/response/cipher.response";
|
||||
import { AttachmentView } from "../models/view/attachment.view";
|
||||
@@ -1168,6 +1169,34 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
async getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<CipherWithIdRequest[]> {
|
||||
if (originalUserKey == null) {
|
||||
throw new Error("Original user key is required to rotate ciphers");
|
||||
}
|
||||
if (newUserKey == null) {
|
||||
throw new Error("New user key is required to rotate ciphers");
|
||||
}
|
||||
|
||||
let encryptedCiphers: CipherWithIdRequest[] = [];
|
||||
|
||||
const ciphers = await this.getAllDecrypted();
|
||||
if (!ciphers || ciphers.length === 0) {
|
||||
return encryptedCiphers;
|
||||
}
|
||||
encryptedCiphers = await Promise.all(
|
||||
ciphers.map(async (cipher) => {
|
||||
const encryptedCipher = await this.encrypt(cipher, newUserKey, originalUserKey);
|
||||
return new CipherWithIdRequest(encryptedCipher);
|
||||
}),
|
||||
);
|
||||
|
||||
return encryptedCiphers;
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
// In the case of a cipher that is being shared with an organization, we want to decrypt the
|
||||
|
||||
@@ -178,6 +178,29 @@ describe("Folder Service", () => {
|
||||
// });
|
||||
});
|
||||
|
||||
describe("getRotatedData", () => {
|
||||
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
let encryptedKey: EncString;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptedKey = new EncString("Re-encrypted Folder");
|
||||
cryptoService.encrypt.mockResolvedValue(encryptedKey);
|
||||
});
|
||||
|
||||
it("returns re-encrypted user folders", async () => {
|
||||
const result = await folderService.getRotatedData(originalUserKey, newUserKey, mockUserId);
|
||||
|
||||
expect(result[0]).toMatchObject({ id: "1", name: "Re-encrypted Folder" });
|
||||
});
|
||||
|
||||
it("throws if the new user key is null", async () => {
|
||||
await expect(folderService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow(
|
||||
"New user key is required for rotation.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function folderData(id: string, name: string) {
|
||||
const data = new FolderData({} as any);
|
||||
data.id = id;
|
||||
|
||||
@@ -6,12 +6,14 @@ import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction";
|
||||
import { FolderData } from "../../../vault/models/data/folder.data";
|
||||
import { Folder } from "../../../vault/models/domain/folder";
|
||||
import { FolderView } from "../../../vault/models/view/folder.view";
|
||||
import { Cipher } from "../../models/domain/cipher";
|
||||
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 {
|
||||
@@ -170,4 +172,27 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
decryptedFolders.push(noneFolder);
|
||||
return decryptedFolders;
|
||||
}
|
||||
|
||||
async getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<FolderWithIdRequest[]> {
|
||||
if (newUserKey == null) {
|
||||
throw new Error("New user key is required for rotation.");
|
||||
}
|
||||
|
||||
let encryptedFolders: FolderWithIdRequest[] = [];
|
||||
const folders = await firstValueFrom(this.folderViews$);
|
||||
if (!folders) {
|
||||
return encryptedFolders;
|
||||
}
|
||||
encryptedFolders = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
const encryptedFolder = await this.encrypt(folder, newUserKey);
|
||||
return new FolderWithIdRequest(encryptedFolder);
|
||||
}),
|
||||
);
|
||||
return encryptedFolders;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user