1
0
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:
Jake Fink
2024-06-20 11:36:24 -04:00
committed by GitHub
parent eadb1fa4ef
commit b306554675
23 changed files with 516 additions and 196 deletions

View File

@@ -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[]>;
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}
}