mirror of
https://github.com/bitwarden/browser
synced 2025-12-23 19:53:43 +00:00
[PM-3797] Client changes to use new key rotation process (#6881)
## Type of change <!-- (mark with an `X`) --> ``` - [ ] Bug fix - [ ] New feature development - [x] Tech debt (refactoring, code cleanup, dependency upgrades, etc) - [ ] Build/deploy pipeline (DevOps) - [ ] Other ``` ## Objective <!--Describe what the purpose of this PR is. For example: what bug you're fixing or what new feature you're adding--> Final Client changes for Key Rotation Improvements. - Introduces a new `KeyRotationService` that is responsible for owning rotation process. - Moves `Send` re-encryption to the `SendService` (`KeyRotationService` shouldn't have knowledge about how domains are encrypted). - Moves `EmergencyAccess` re-encryption to the `EmergencyAccessService`. - Renames `AccountRecoveryService` to `OrganizationUserResetPasswordService` after feedback from Admin Console ## Code changes <!--Explain the changes you've made to each file or major component. This should help the reviewer understand your changes--> <!--Also refer to any related changes or PRs in other repositories--> Auth - **emergency-access-update.request.ts:** New request model for domain updates that includes Id - **emergency-access.service.ts:** Moved `EmergencyAccess` re-encryption to the `EmergencyAccessService`. Add deprecated method for legacy key rotations if feature flag is off - **key-rotation.service/api/spec/module:** New key rotation service for owning the rotation process. Added api service, module, and spec file. - **update-key.request.ts:** Moved to Auth ownership. Also added new properties for including other domains. - **migrate-legacy-encryption.component.ts:** Use new key rotation service instead of old component specific service. Delete old service. - **change-password.component.ts:** Use new key rotation service. - **settings.module.ts:** Import key rotation module. Admin Console - **organization-user-reset-password.service.ts/spec:** Responsible for re-encryption of reset password keys during key rotation. Added tests. - **organization-user-reset-password-enrollment.request.ts:** New request model for key rotations - **reset-password.component.ts:** Update `AccountRecoveryService` to `OrganizationUserResetPasswordService` - **enroll-master-password-reset.component.ts:** Update `AccountRecoveryService` to `OrganizationUserResetPasswordService` Tools - **send.service/spec.ts:** Responsible only for re-encryption of sends during key rotation. Added tests. Other - **api.service.ts:** Move `postAccountKey` to `KeyRotationApiService` - **feature-flag.enum.ts:** add new feature flag ## Screenshots <!--Required for any UI changes. Delete if not applicable--> ## Before you submit - Please add **unit tests** where it makes sense to do so (encouraged but not required) - If this change requires a **documentation update** - notify the documentation team - If this change has particular **deployment requirements** - notify the DevOps team - Ensure that all UI additions follow [WCAG AA requirements](https://contributing.bitwarden.com/contributing/accessibility/)
This commit is contained in:
@@ -8,16 +8,14 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { SharedModule } from "../../shared";
|
||||
import { EmergencyAccessModule } from "../emergency-access";
|
||||
|
||||
import { MigrateFromLegacyEncryptionService } from "./migrate-legacy-encryption.service";
|
||||
import { UserKeyRotationModule } from "../key-rotation/user-key-rotation.module";
|
||||
import { UserKeyRotationService } from "../key-rotation/user-key-rotation.service";
|
||||
|
||||
// The master key was originally used to encrypt user data, before the user key was introduced.
|
||||
// This component is used to migrate from the old encryption scheme to the new one.
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [SharedModule, EmergencyAccessModule],
|
||||
providers: [MigrateFromLegacyEncryptionService],
|
||||
imports: [SharedModule, UserKeyRotationModule],
|
||||
templateUrl: "migrate-legacy-encryption.component.html",
|
||||
})
|
||||
export class MigrateFromLegacyEncryptionComponent {
|
||||
@@ -26,9 +24,9 @@ export class MigrateFromLegacyEncryptionComponent {
|
||||
});
|
||||
|
||||
constructor(
|
||||
private keyRotationService: UserKeyRotationService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private migrationService: MigrateFromLegacyEncryptionService,
|
||||
private cryptoService: CryptoService,
|
||||
private messagingService: MessagingService,
|
||||
private logService: LogService,
|
||||
@@ -50,22 +48,7 @@ export class MigrateFromLegacyEncryptionComponent {
|
||||
const masterPassword = this.formGroup.value.masterPassword;
|
||||
|
||||
try {
|
||||
// Create new user key
|
||||
const [newUserKey, masterKeyEncUserKey] =
|
||||
await this.migrationService.createNewUserKey(masterPassword);
|
||||
|
||||
// Update admin recover keys
|
||||
await this.migrationService.updateAllAdminRecoveryKeys(masterPassword, newUserKey);
|
||||
|
||||
// Update emergency access
|
||||
await this.migrationService.updateEmergencyAccesses(newUserKey);
|
||||
|
||||
// Update keys, folders, ciphers, and sends
|
||||
await this.migrationService.updateKeysAndEncryptedData(
|
||||
masterPassword,
|
||||
newUserKey,
|
||||
masterKeyEncUserKey,
|
||||
);
|
||||
await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword);
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import {
|
||||
MasterKey,
|
||||
SymmetricCryptoKey,
|
||||
UserKey,
|
||||
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { AccountRecoveryService } from "../../admin-console/organizations/members/services/account-recovery/account-recovery.service";
|
||||
import { EmergencyAccessService } from "../emergency-access";
|
||||
|
||||
import { MigrateFromLegacyEncryptionService } from "./migrate-legacy-encryption.service";
|
||||
|
||||
describe("migrateFromLegacyEncryptionService", () => {
|
||||
let migrateFromLegacyEncryptionService: MigrateFromLegacyEncryptionService;
|
||||
|
||||
const emergencyAccessService = mock<EmergencyAccessService>();
|
||||
const accountRecoveryService = mock<AccountRecoveryService>();
|
||||
const apiService = mock<ApiService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const syncService = mock<SyncService>();
|
||||
const cipherService = mock<CipherService>();
|
||||
const folderService = mock<FolderService>();
|
||||
const sendService = mock<SendService>();
|
||||
const stateService = mock<StateService>();
|
||||
let folderViews: BehaviorSubject<FolderView[]>;
|
||||
let sends: BehaviorSubject<Send[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
migrateFromLegacyEncryptionService = new MigrateFromLegacyEncryptionService(
|
||||
emergencyAccessService,
|
||||
accountRecoveryService,
|
||||
apiService,
|
||||
cryptoService,
|
||||
encryptService,
|
||||
syncService,
|
||||
cipherService,
|
||||
folderService,
|
||||
sendService,
|
||||
stateService,
|
||||
);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(migrateFromLegacyEncryptionService).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("createNewUserKey", () => {
|
||||
it("validates master password and legacy user", async () => {
|
||||
const mockMasterPassword = "mockMasterPassword";
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
const mockMasterKey = new SymmetricCryptoKey(mockRandomBytes) as MasterKey;
|
||||
stateService.getEmail.mockResolvedValue("mockEmail");
|
||||
stateService.getKdfType.mockResolvedValue(KdfType.PBKDF2_SHA256);
|
||||
stateService.getKdfConfig.mockResolvedValue({ iterations: 100000 });
|
||||
cryptoService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
cryptoService.isLegacyUser.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
migrateFromLegacyEncryptionService.createNewUserKey(mockMasterPassword),
|
||||
).rejects.toThrowError("Invalid master password or user may not be legacy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateKeysAndEncryptedData", () => {
|
||||
let mockMasterPassword: string;
|
||||
let mockUserKey: UserKey;
|
||||
let mockEncUserKey: EncString;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMasterPassword = "mockMasterPassword";
|
||||
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
mockEncUserKey = new EncString("mockEncUserKey");
|
||||
|
||||
const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
|
||||
const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
|
||||
const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
|
||||
|
||||
cryptoService.getPrivateKey.mockResolvedValue(new Uint8Array(64) as CsprngArray);
|
||||
cryptoService.rsaEncrypt.mockResolvedValue(
|
||||
new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "Encrypted"),
|
||||
);
|
||||
|
||||
folderViews = new BehaviorSubject<FolderView[]>(mockFolders);
|
||||
folderService.folderViews$ = folderViews;
|
||||
|
||||
cipherService.getAllDecrypted.mockResolvedValue(mockCiphers);
|
||||
|
||||
sends = new BehaviorSubject<Send[]>(mockSends);
|
||||
sendService.sends$ = sends;
|
||||
|
||||
encryptService.encrypt.mockImplementation((plainValue, userKey) => {
|
||||
return Promise.resolve(
|
||||
new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "Encrypted: " + plainValue),
|
||||
);
|
||||
});
|
||||
|
||||
folderService.encrypt.mockImplementation((folder, userKey) => {
|
||||
const encryptedFolder = new Folder();
|
||||
encryptedFolder.id = folder.id;
|
||||
encryptedFolder.name = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"Encrypted: " + folder.name,
|
||||
);
|
||||
return Promise.resolve(encryptedFolder);
|
||||
});
|
||||
|
||||
cipherService.encrypt.mockImplementation((cipher, userKey) => {
|
||||
const encryptedCipher = new Cipher();
|
||||
encryptedCipher.id = cipher.id;
|
||||
encryptedCipher.name = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"Encrypted: " + cipher.name,
|
||||
);
|
||||
return Promise.resolve(encryptedCipher);
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the master key in case it hasn't been set", async () => {
|
||||
await migrateFromLegacyEncryptionService.updateKeysAndEncryptedData(
|
||||
mockMasterPassword,
|
||||
mockUserKey,
|
||||
mockEncUserKey,
|
||||
);
|
||||
|
||||
expect(cryptoService.getOrDeriveMasterKey).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs latest data", async () => {
|
||||
await migrateFromLegacyEncryptionService.updateKeysAndEncryptedData(
|
||||
mockMasterPassword,
|
||||
mockUserKey,
|
||||
mockEncUserKey,
|
||||
);
|
||||
expect(syncService.fullSync).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does not post new account data if sync fails", async () => {
|
||||
syncService.fullSync.mockRejectedValueOnce(new Error("sync failed"));
|
||||
|
||||
await expect(
|
||||
migrateFromLegacyEncryptionService.updateKeysAndEncryptedData(
|
||||
mockMasterPassword,
|
||||
mockUserKey,
|
||||
mockEncUserKey,
|
||||
),
|
||||
).rejects.toThrowError("sync failed");
|
||||
|
||||
expect(apiService.postAccountKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not post new account data if data retrieval fails", async () => {
|
||||
(migrateFromLegacyEncryptionService as any).encryptCiphers = async () => {
|
||||
throw new Error("Ciphers failed to be retrieved");
|
||||
};
|
||||
|
||||
await expect(
|
||||
migrateFromLegacyEncryptionService.updateKeysAndEncryptedData(
|
||||
mockMasterPassword,
|
||||
mockUserKey,
|
||||
mockEncUserKey,
|
||||
),
|
||||
).rejects.toThrowError("Ciphers failed to be retrieved");
|
||||
|
||||
expect(apiService.postAccountKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateEmergencyAccesses", () => {
|
||||
let mockUserKey: UserKey;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
});
|
||||
|
||||
it("Uses emergency access service to rotate", async () => {
|
||||
await migrateFromLegacyEncryptionService.updateEmergencyAccesses(mockUserKey);
|
||||
|
||||
expect(emergencyAccessService.rotate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createMockFolder(id: string, name: string): FolderView {
|
||||
const folder = new FolderView();
|
||||
folder.id = id;
|
||||
folder.name = name;
|
||||
return folder;
|
||||
}
|
||||
|
||||
function createMockCipher(id: string, name: string): CipherView {
|
||||
const cipher = new CipherView();
|
||||
cipher.id = id;
|
||||
cipher.name = name;
|
||||
return cipher;
|
||||
}
|
||||
|
||||
function createMockSend(id: string, name: string): Send {
|
||||
const send = new Send();
|
||||
send.id = id;
|
||||
send.name = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, name);
|
||||
return send;
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { UpdateKeyRequest } from "@bitwarden/common/models/request/update-key.request";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
|
||||
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
|
||||
|
||||
import { AccountRecoveryService } from "../../admin-console/organizations/members/services/account-recovery/account-recovery.service";
|
||||
import { EmergencyAccessService } from "../emergency-access";
|
||||
|
||||
// TODO: PM-3797 - This service should be expanded and used for user key rotations in change-password.component.ts
|
||||
@Injectable()
|
||||
export class MigrateFromLegacyEncryptionService {
|
||||
constructor(
|
||||
private emergencyAccessService: EmergencyAccessService,
|
||||
private accountRecoveryService: AccountRecoveryService,
|
||||
private apiService: ApiService,
|
||||
private cryptoService: CryptoService,
|
||||
private encryptService: EncryptService,
|
||||
private syncService: SyncService,
|
||||
private cipherService: CipherService,
|
||||
private folderService: FolderService,
|
||||
private sendService: SendService,
|
||||
private stateService: StateService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validates the master password and creates a new user key.
|
||||
* @returns A new user key along with the encrypted version
|
||||
*/
|
||||
async createNewUserKey(masterPassword: string): Promise<[UserKey, EncString]> {
|
||||
// Create master key to validate the master password
|
||||
const masterKey = await this.cryptoService.makeMasterKey(
|
||||
masterPassword,
|
||||
await this.stateService.getEmail(),
|
||||
await this.stateService.getKdfType(),
|
||||
await this.stateService.getKdfConfig(),
|
||||
);
|
||||
|
||||
if (!masterKey) {
|
||||
throw new Error("Invalid master password");
|
||||
}
|
||||
|
||||
if (!(await this.cryptoService.isLegacyUser(masterKey))) {
|
||||
throw new Error("Invalid master password or user may not be legacy");
|
||||
}
|
||||
|
||||
// Set master key again in case it was lost (could be lost on refresh)
|
||||
await this.cryptoService.setMasterKey(masterKey);
|
||||
return await this.cryptoService.makeUserKey(masterKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the user key, master key hash, private key, folders, ciphers, and sends
|
||||
* on the server.
|
||||
* @param masterPassword The master password
|
||||
* @param newUserKey The new user key
|
||||
* @param newEncUserKey The new encrypted user key
|
||||
*/
|
||||
async updateKeysAndEncryptedData(
|
||||
masterPassword: string,
|
||||
newUserKey: UserKey,
|
||||
newEncUserKey: EncString,
|
||||
): Promise<void> {
|
||||
// Create new request and add master key and hash
|
||||
const request = new UpdateKeyRequest();
|
||||
request.key = newEncUserKey.encryptedString;
|
||||
request.masterPasswordHash = await this.cryptoService.hashMasterKey(
|
||||
masterPassword,
|
||||
await this.cryptoService.getOrDeriveMasterKey(masterPassword),
|
||||
);
|
||||
|
||||
// Sync before encrypting to make sure we have latest data
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
request.privateKey = await this.encryptPrivateKey(newUserKey);
|
||||
request.folders = await this.encryptFolders(newUserKey);
|
||||
request.ciphers = await this.encryptCiphers(newUserKey);
|
||||
request.sends = await this.encryptSends(newUserKey);
|
||||
|
||||
return this.apiService.postAccountKey(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user's emergency access details from server and encrypts with new user key
|
||||
* on the server.
|
||||
* @param newUserKey The new user key
|
||||
*/
|
||||
updateEmergencyAccesses(newUserKey: UserKey) {
|
||||
return this.emergencyAccessService.rotate(newUserKey);
|
||||
}
|
||||
|
||||
/** Updates all admin recovery keys on the server with the new user key
|
||||
* @param masterPassword The user's master password
|
||||
* @param newUserKey The new user key
|
||||
*/
|
||||
async updateAllAdminRecoveryKeys(masterPassword: string, newUserKey: UserKey) {
|
||||
const masterPasswordHash = await this.cryptoService.hashMasterKey(
|
||||
masterPassword,
|
||||
await this.cryptoService.getOrDeriveMasterKey(masterPassword),
|
||||
);
|
||||
await this.accountRecoveryService.rotate(newUserKey, masterPasswordHash);
|
||||
}
|
||||
|
||||
private async encryptPrivateKey(newUserKey: UserKey): Promise<EncryptedString | null> {
|
||||
const privateKey = await this.cryptoService.getPrivateKey();
|
||||
if (!privateKey) {
|
||||
return;
|
||||
}
|
||||
return (await this.encryptService.encrypt(privateKey, newUserKey)).encryptedString;
|
||||
}
|
||||
|
||||
private async encryptFolders(newUserKey: UserKey): Promise<FolderWithIdRequest[] | null> {
|
||||
const folders = await firstValueFrom(this.folderService.folderViews$);
|
||||
if (!folders) {
|
||||
return;
|
||||
}
|
||||
return await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
const encryptedFolder = await this.folderService.encrypt(folder, newUserKey);
|
||||
return new FolderWithIdRequest(encryptedFolder);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async encryptCiphers(newUserKey: UserKey): Promise<CipherWithIdRequest[] | null> {
|
||||
const ciphers = await this.cipherService.getAllDecrypted();
|
||||
if (!ciphers) {
|
||||
return;
|
||||
}
|
||||
return await Promise.all(
|
||||
ciphers.map(async (cipher) => {
|
||||
const encryptedCipher = await this.cipherService.encrypt(cipher, newUserKey);
|
||||
return new CipherWithIdRequest(encryptedCipher);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async encryptSends(newUserKey: UserKey): Promise<SendWithIdRequest[] | null> {
|
||||
const sends = await firstValueFrom(this.sendService.sends$);
|
||||
if (!sends) {
|
||||
return;
|
||||
}
|
||||
return await Promise.all(
|
||||
sends.map(async (send) => {
|
||||
const sendKey = await this.encryptService.decryptToBytes(send.key, null);
|
||||
send.key = (await this.encryptService.encrypt(sendKey, newUserKey)) ?? send.key;
|
||||
return new SendWithIdRequest(send);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user