diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts
index ac2d2d4116a..e7b825e6ce2 100644
--- a/apps/browser/src/background/main.background.ts
+++ b/apps/browser/src/background/main.background.ts
@@ -437,7 +437,7 @@ export default class MainBackground {
constructor() {
// Services
- const lockedCallback = async (userId?: string) => {
+ const lockedCallback = async (userId: UserId) => {
await this.refreshBadge();
await this.refreshMenu(true);
if (this.systemService != null) {
diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html
index 6e6e30b359b..f9be1617d21 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html
@@ -31,7 +31,11 @@
{{ "clone" | i18n }}
-
+
{{ "assignToCollections" | i18n }}
diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts
index 165dd6d6d30..75bc984e977 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts
@@ -1,11 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
-import { booleanAttribute, Component, Input, OnInit } from "@angular/core";
+import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
-import { BehaviorSubject, firstValueFrom, map, switchMap } from "rxjs";
+import { BehaviorSubject, combineLatest, firstValueFrom, map, switchMap } from "rxjs";
import { filter } from "rxjs/operators";
+import { CollectionService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -32,7 +33,7 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
templateUrl: "./item-more-options.component.html",
imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule],
})
-export class ItemMoreOptionsComponent implements OnInit {
+export class ItemMoreOptionsComponent {
private _cipher$ = new BehaviorSubject(undefined);
@Input({
@@ -71,8 +72,21 @@ export class ItemMoreOptionsComponent implements OnInit {
switchMap((c) => this.cipherAuthorizationService.canCloneCipher$(c)),
);
- /** Boolean dependent on the current user having access to an organization */
- protected hasOrganizations = false;
+ /** Observable Boolean dependent on the current user having access to an organization and editable collections */
+ protected canAssignCollections$ = this.accountService.activeAccount$.pipe(
+ getUserId,
+ switchMap((userId) => {
+ return combineLatest([
+ this.organizationService.hasOrganizations(userId),
+ this.collectionService.decryptedCollections$,
+ ]).pipe(
+ map(([hasOrgs, collections]) => {
+ const canEditCollections = collections.some((c) => !c.readOnly);
+ return hasOrgs && canEditCollections;
+ }),
+ );
+ }),
+ );
constructor(
private cipherService: CipherService,
@@ -85,13 +99,9 @@ export class ItemMoreOptionsComponent implements OnInit {
private accountService: AccountService,
private organizationService: OrganizationService,
private cipherAuthorizationService: CipherAuthorizationService,
+ private collectionService: CollectionService,
) {}
- async ngOnInit(): Promise {
- const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
- this.hasOrganizations = await firstValueFrom(this.organizationService.hasOrganizations(userId));
- }
-
get canEdit() {
return this.cipher.edit;
}
diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts
index b934b430370..05437e3e3d3 100644
--- a/apps/cli/src/service-container/service-container.ts
+++ b/apps/cli/src/service-container/service-container.ts
@@ -715,8 +715,8 @@ export class ServiceContainer {
this.folderApiService = new FolderApiService(this.folderService, this.apiService);
- const lockedCallback = async (userId?: string) =>
- await this.keyService.clearStoredUserKey(KeySuffixOptions.Auto);
+ const lockedCallback = async (userId: UserId) =>
+ await this.keyService.clearStoredUserKey(KeySuffixOptions.Auto, userId);
this.userVerificationApiService = new UserVerificationApiService(this.apiService);
diff --git a/apps/desktop/src/key-management/electron-key.service.ts b/apps/desktop/src/key-management/electron-key.service.ts
index bc18152b0fb..8a6fbfa085f 100644
--- a/apps/desktop/src/key-management/electron-key.service.ts
+++ b/apps/desktop/src/key-management/electron-key.service.ts
@@ -55,7 +55,7 @@ export class ElectronKeyService extends DefaultKeyService {
return super.hasUserKeyStored(keySuffix, userId);
}
- override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise {
+ override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId: UserId): Promise {
await super.clearStoredUserKey(keySuffix, userId);
}
diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.html b/apps/desktop/src/vault/app/vault/add-edit.component.html
index 8457e72bdc1..9c316813d1d 100644
--- a/apps/desktop/src/vault/app/vault/add-edit.component.html
+++ b/apps/desktop/src/vault/app/vault/add-edit.component.html
@@ -788,7 +788,13 @@
diff --git a/apps/web/src/app/key-management/key-rotation/request/update-key.request.ts b/apps/web/src/app/key-management/key-rotation/request/update-key.request.ts
deleted file mode 100644
index e8d9f0c4d09..00000000000
--- a/apps/web/src/app/key-management/key-rotation/request/update-key.request.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
-import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
-import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
-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 { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request";
-
-export class UpdateKeyRequest {
- masterPasswordHash: string;
- key: string;
- privateKey: string;
- ciphers: CipherWithIdRequest[] = [];
- folders: FolderWithIdRequest[] = [];
- sends: SendWithIdRequest[] = [];
- emergencyAccessKeys: EmergencyAccessWithIdRequest[] = [];
- resetPasswordKeys: OrganizationUserResetPasswordWithIdRequest[] = [];
- webauthnKeys: WebauthnRotateCredentialRequest[] = [];
-
- constructor(masterPasswordHash: string, key: string, privateKey: string) {
- this.masterPasswordHash = masterPasswordHash;
- this.key = key;
- this.privateKey = privateKey;
- }
-}
diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts
index 2a947359bcb..bbacdc9bc96 100644
--- a/apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts
+++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts
@@ -3,17 +3,12 @@ import { inject, Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request";
-import { UpdateKeyRequest } from "./request/update-key.request";
@Injectable()
export class UserKeyRotationApiService {
readonly apiService = inject(ApiService);
- postUserKeyUpdate(request: UpdateKeyRequest): Promise {
- return this.apiService.send("POST", "/accounts/key", request, true, false);
- }
-
- postUserKeyUpdateV2(request: RotateUserAccountKeysRequest): Promise {
+ postUserKeyUpdate(request: RotateUserAccountKeysRequest): Promise {
return this.apiService.send(
"POST",
"/accounts/key-management/rotate-user-account-keys",
diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts
index 4dc5a206a63..4bc3b7b4fea 100644
--- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts
+++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts
@@ -2,8 +2,9 @@ import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
-import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
+import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
+import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
@@ -11,11 +12,12 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
+import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } 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 { UserId } from "@bitwarden/common/types/guid";
-import { UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
+import { MasterKey, UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
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";
@@ -23,7 +25,12 @@ import { CipherType } from "@bitwarden/common/vault/enums";
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 { DialogService, ToastService } from "@bitwarden/components";
-import { KeyService, DEFAULT_KDF_CONFIG } from "@bitwarden/key-management";
+import {
+ KeyService,
+ PBKDF2KdfConfig,
+ KdfConfigService,
+ KdfConfig,
+} from "@bitwarden/key-management";
import {
AccountRecoveryTrustComponent,
EmergencyAccessTrustComponent,
@@ -38,6 +45,9 @@ import { EmergencyAccessStatusType } from "../../auth/emergency-access/enums/eme
import { EmergencyAccessType } from "../../auth/emergency-access/enums/emergency-access-type";
import { EmergencyAccessWithIdRequest } from "../../auth/emergency-access/request/emergency-access-update.request";
+import { MasterPasswordUnlockDataRequest } from "./request/master-password-unlock-data.request";
+import { UnlockDataRequest } from "./request/unlock-data.request";
+import { UserDataRequest } from "./request/userdata.request";
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
import { UserKeyRotationService } from "./user-key-rotation.service";
@@ -64,359 +74,6 @@ accountRecoveryTrustOpenUntrusted.mockReturnValue({
closed: new BehaviorSubject(false),
});
-describe("KeyRotationService", () => {
- let keyRotationService: UserKeyRotationService;
-
- let mockUserVerificationService: MockProxy;
- let mockApiService: MockProxy;
- let mockCipherService: MockProxy;
- let mockFolderService: MockProxy;
- let mockSendService: MockProxy;
- let mockEmergencyAccessService: MockProxy;
- let mockResetPasswordService: MockProxy;
- let mockDeviceTrustService: MockProxy;
- let mockKeyService: MockProxy;
- let mockEncryptService: MockProxy;
- let mockConfigService: MockProxy;
- let mockSyncService: MockProxy;
- let mockWebauthnLoginAdminService: MockProxy;
- let mockLogService: MockProxy;
- let mockVaultTimeoutService: MockProxy;
- let mockDialogService: MockProxy;
- let mockToastService: MockProxy;
- let mockI18nService: MockProxy;
-
- const mockUser = {
- id: "mockUserId" as UserId,
- email: "mockEmail",
- emailVerified: true,
- name: "mockName",
- };
-
- const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
-
- beforeAll(() => {
- jest.spyOn(PureCrypto, "make_user_key_aes256_cbc_hmac").mockReturnValue(new Uint8Array(64));
- jest.spyOn(PureCrypto, "make_user_key_xchacha20_poly1305").mockReturnValue(new Uint8Array(70));
- jest
- .spyOn(PureCrypto, "encrypt_user_key_with_master_password")
- .mockReturnValue("mockNewUserKey");
- mockUserVerificationService = mock();
- mockApiService = mock();
- mockCipherService = mock();
- mockFolderService = mock();
- mockSendService = mock();
- mockEmergencyAccessService = mock();
- mockEmergencyAccessService.getPublicKeys.mockResolvedValue(
- mockTrustedPublicKeys.map((key) => {
- return {
- publicKey: key,
- id: "mockId",
- granteeId: "mockGranteeId",
- name: "mockName",
- email: "mockEmail",
- type: EmergencyAccessType.Takeover,
- status: EmergencyAccessStatusType.Accepted,
- waitTimeDays: 5,
- creationDate: "mockCreationDate",
- avatarColor: "mockAvatarColor",
- };
- }),
- );
- mockResetPasswordService = mock();
- mockResetPasswordService.getPublicKeys.mockResolvedValue(
- mockTrustedPublicKeys.map((key) => {
- return {
- publicKey: key,
- orgId: "mockOrgId",
- orgName: "mockOrgName",
- };
- }),
- );
- mockDeviceTrustService = mock();
- mockKeyService = mock();
- mockEncryptService = mock();
- mockConfigService = mock();
- mockSyncService = mock();
- mockWebauthnLoginAdminService = mock();
- mockLogService = mock();
- mockVaultTimeoutService = mock();
- mockToastService = mock();
- mockI18nService = mock();
- mockDialogService = mock();
-
- keyRotationService = new UserKeyRotationService(
- mockUserVerificationService,
- mockApiService,
- mockCipherService,
- mockFolderService,
- mockSendService,
- mockEmergencyAccessService,
- mockResetPasswordService,
- mockDeviceTrustService,
- mockKeyService,
- mockEncryptService,
- mockSyncService,
- mockWebauthnLoginAdminService,
- mockLogService,
- mockVaultTimeoutService,
- mockToastService,
- mockI18nService,
- mockDialogService,
- mockConfigService,
- );
- });
-
- beforeEach(() => {
- jest.mock("@bitwarden/key-management-ui");
- });
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- describe("rotateUserKeyAndEncryptedData", () => {
- let privateKey: BehaviorSubject;
- let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>;
-
- beforeEach(() => {
- mockKeyService.makeUserKey.mockResolvedValue([
- new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
- {
- encryptedString: "mockNewUserKey",
- } as any,
- ]);
- mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
- mockConfigService.getFeatureFlag.mockResolvedValue(false);
-
- mockEncryptService.wrapSymmetricKey.mockResolvedValue({
- encryptedString: "mockEncryptedData",
- } as any);
- mockEncryptService.wrapDecapsulationKey.mockResolvedValue({
- encryptedString: "mockEncryptedData",
- } as any);
-
- // Mock user verification
- mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({
- masterKey: "mockMasterKey" as any,
- kdfConfig: DEFAULT_KDF_CONFIG,
- email: "mockEmail",
- policyOptions: null,
- });
-
- // Mock user key
- mockKeyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
-
- mockKeyService.getFingerprint.mockResolvedValue(["a", "b"]);
-
- // Mock private key
- privateKey = new BehaviorSubject("mockPrivateKey" as any);
- mockKeyService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey);
-
- keyPair = new BehaviorSubject({
- privateKey: "mockPrivateKey",
- publicKey: "mockPublicKey",
- } as any);
- mockKeyService.userEncryptionKeyPair$.mockReturnValue(keyPair);
-
- // Mock ciphers
- const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
- mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
-
- // Mock folders
- const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
- mockFolderService.getRotatedData.mockResolvedValue(mockFolders);
-
- // Mock sends
- const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
- mockSendService.getRotatedData.mockResolvedValue(mockSends);
-
- // Mock emergency access
- const emergencyAccess = [createMockEmergencyAccess("13")];
- mockEmergencyAccessService.getRotatedData.mockResolvedValue(emergencyAccess);
-
- // Mock reset password
- const resetPassword = [createMockResetPassword("12")];
- mockResetPasswordService.getRotatedData.mockResolvedValue(resetPassword);
-
- // Mock Webauthn
- const webauthn = [createMockWebauthn("13"), createMockWebauthn("14")];
- mockWebauthnLoginAdminService.getRotatedData.mockResolvedValue(webauthn);
- });
-
- it("rotates the userkey and encrypted data and changes master password", async () => {
- KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
- EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
- AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
- await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
- "mockMasterPassword",
- "newMasterPassword",
- mockUser,
- );
-
- expect(mockApiService.postUserKeyUpdateV2).toHaveBeenCalled();
- const arg = mockApiService.postUserKeyUpdateV2.mock.calls[0][0];
- expect(arg.accountUnlockData.masterPasswordUnlockData.masterKeyEncryptedUserKey).toBe(
- "mockNewUserKey",
- );
- expect(arg.oldMasterKeyAuthenticationHash).toBe("mockMasterPasswordHash");
- expect(arg.accountUnlockData.masterPasswordUnlockData.email).toBe("mockEmail");
- expect(arg.accountUnlockData.masterPasswordUnlockData.kdfType).toBe(
- DEFAULT_KDF_CONFIG.kdfType,
- );
- expect(arg.accountUnlockData.masterPasswordUnlockData.kdfIterations).toBe(
- DEFAULT_KDF_CONFIG.iterations,
- );
-
- expect(arg.accountKeys.accountPublicKey).toBe(Utils.fromUtf8ToB64("mockPublicKey"));
- expect(arg.accountKeys.userKeyEncryptedAccountPrivateKey).toBe("mockEncryptedData");
-
- expect(arg.accountData.ciphers.length).toBe(2);
- expect(arg.accountData.folders.length).toBe(2);
- expect(arg.accountData.sends.length).toBe(2);
- expect(arg.accountUnlockData.emergencyAccessUnlockData.length).toBe(1);
- expect(arg.accountUnlockData.organizationAccountRecoveryUnlockData.length).toBe(1);
- expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2);
- expect(PureCrypto.make_user_key_aes256_cbc_hmac).toHaveBeenCalled();
- expect(PureCrypto.encrypt_user_key_with_master_password).toHaveBeenCalledWith(
- new Uint8Array(64),
- "newMasterPassword",
- mockUser.email,
- DEFAULT_KDF_CONFIG.toSdkConfig(),
- );
- expect(PureCrypto.make_user_key_xchacha20_poly1305).not.toHaveBeenCalled();
- });
-
- it("rotates the userkey to xchacha20poly1305 and encrypted data and changes master password when featureflag is active", async () => {
- mockConfigService.getFeatureFlag.mockResolvedValue(true);
-
- KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
- EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
- AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
- await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
- "mockMasterPassword",
- "newMasterPassword",
- mockUser,
- );
-
- expect(mockApiService.postUserKeyUpdateV2).toHaveBeenCalled();
- const arg = mockApiService.postUserKeyUpdateV2.mock.calls[0][0];
- expect(arg.accountUnlockData.masterPasswordUnlockData.masterKeyEncryptedUserKey).toBe(
- "mockNewUserKey",
- );
- expect(arg.oldMasterKeyAuthenticationHash).toBe("mockMasterPasswordHash");
- expect(arg.accountUnlockData.masterPasswordUnlockData.email).toBe("mockEmail");
- expect(arg.accountUnlockData.masterPasswordUnlockData.kdfType).toBe(
- DEFAULT_KDF_CONFIG.kdfType,
- );
- expect(arg.accountUnlockData.masterPasswordUnlockData.kdfIterations).toBe(
- DEFAULT_KDF_CONFIG.iterations,
- );
-
- expect(arg.accountKeys.accountPublicKey).toBe(Utils.fromUtf8ToB64("mockPublicKey"));
- expect(arg.accountKeys.userKeyEncryptedAccountPrivateKey).toBe("mockEncryptedData");
-
- expect(arg.accountData.ciphers.length).toBe(2);
- expect(arg.accountData.folders.length).toBe(2);
- expect(arg.accountData.sends.length).toBe(2);
- expect(arg.accountUnlockData.emergencyAccessUnlockData.length).toBe(1);
- expect(arg.accountUnlockData.organizationAccountRecoveryUnlockData.length).toBe(1);
- expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2);
- expect(PureCrypto.make_user_key_aes256_cbc_hmac).not.toHaveBeenCalled();
- expect(PureCrypto.encrypt_user_key_with_master_password).toHaveBeenCalledWith(
- new Uint8Array(70),
- "newMasterPassword",
- mockUser.email,
- DEFAULT_KDF_CONFIG.toSdkConfig(),
- );
- expect(PureCrypto.make_user_key_xchacha20_poly1305).toHaveBeenCalled();
- });
-
- it("returns early when first trust warning dialog is declined", async () => {
- KeyRotationTrustInfoComponent.open = initialPromptedOpenFalse;
- EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
- AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
- await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
- "mockMasterPassword",
- "newMasterPassword",
- mockUser,
- );
- expect(mockApiService.postUserKeyUpdateV2).not.toHaveBeenCalled();
- });
-
- it("returns early when emergency access trust warning dialog is declined", async () => {
- KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
- EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenUntrusted;
- AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
- await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
- "mockMasterPassword",
- "newMasterPassword",
- mockUser,
- );
- expect(mockApiService.postUserKeyUpdateV2).not.toHaveBeenCalled();
- });
-
- it("returns early when account recovery trust warning dialog is declined", async () => {
- KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
- EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
- AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenUntrusted;
- await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
- "mockMasterPassword",
- "newMasterPassword",
- mockUser,
- );
- expect(mockApiService.postUserKeyUpdateV2).not.toHaveBeenCalled();
- });
-
- it("throws if master password provided is falsey", async () => {
- await expect(
- keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData("", "", mockUser),
- ).rejects.toThrow();
- });
-
- it("throws if no private key is found", async () => {
- keyPair.next(null);
-
- await expect(
- keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
- "mockMasterPassword",
- "mockMasterPassword1",
- mockUser,
- ),
- ).rejects.toThrow();
- });
-
- it("throws if master password is incorrect", async () => {
- mockUserVerificationService.verifyUserByMasterPassword.mockRejectedValueOnce(
- new Error("Invalid master password"),
- );
-
- await expect(
- keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
- "mockMasterPassword",
- "mockMasterPassword1",
- mockUser,
- ),
- ).rejects.toThrow();
- });
-
- it("throws if server rotation fails", async () => {
- KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
- EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
- AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
- mockApiService.postUserKeyUpdateV2.mockRejectedValueOnce(new Error("mockError"));
-
- await expect(
- keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
- "mockMasterPassword",
- "mockMasterPassword1",
- mockUser,
- ),
- ).rejects.toThrow();
- });
- });
-});
-
function createMockFolder(id: string, name: string): FolderWithIdRequest {
return {
id: id,
@@ -459,3 +116,784 @@ function createMockWebauthn(id: string): any {
id: id,
} as WebauthnRotateCredentialRequest;
}
+
+class TestUserKeyRotationService extends UserKeyRotationService {
+ override rotateUserKeyMasterPasswordAndEncryptedData(
+ currentMasterPassword: string,
+ newMasterPassword: string,
+ user: Account,
+ newMasterPasswordHint?: string,
+ ): Promise {
+ return super.rotateUserKeyMasterPasswordAndEncryptedData(
+ currentMasterPassword,
+ newMasterPassword,
+ user,
+ newMasterPasswordHint,
+ );
+ }
+ override ensureIsAllowedToRotateUserKey(): Promise {
+ return super.ensureIsAllowedToRotateUserKey();
+ }
+ override getNewAccountKeysV1(
+ currentUserKey: UserKey,
+ currentUserKeyWrappedPrivateKey: EncString,
+ ): Promise<{
+ userKey: UserKey;
+ asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string };
+ }> {
+ return super.getNewAccountKeysV1(currentUserKey, currentUserKeyWrappedPrivateKey);
+ }
+ override getNewAccountKeysV2(
+ currentUserKey: UserKey,
+ currentUserKeyWrappedPrivateKey: EncString,
+ ): Promise<{
+ userKey: UserKey;
+ asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string };
+ }> {
+ return super.getNewAccountKeysV2(currentUserKey, currentUserKeyWrappedPrivateKey);
+ }
+ override createMasterPasswordUnlockDataRequest(
+ userKey: UserKey,
+ newUnlockData: {
+ masterPassword: string;
+ masterKeySalt: string;
+ masterKeyKdfConfig: KdfConfig;
+ masterPasswordHint: string;
+ },
+ ): Promise {
+ return super.createMasterPasswordUnlockDataRequest(userKey, newUnlockData);
+ }
+ override getAccountUnlockDataRequest(
+ userId: UserId,
+ currentUserKey: UserKey,
+ newUserKey: UserKey,
+ masterPasswordAuthenticationAndUnlockData: {
+ masterPassword: string;
+ masterKeySalt: string;
+ masterKeyKdfConfig: KdfConfig;
+ masterPasswordHint: string;
+ },
+ trustedEmergencyAccessGranteesPublicKeys: Uint8Array[],
+ trustedOrganizationPublicKeys: Uint8Array[],
+ ): Promise {
+ return super.getAccountUnlockDataRequest(
+ userId,
+ currentUserKey,
+ newUserKey,
+ masterPasswordAuthenticationAndUnlockData,
+ trustedEmergencyAccessGranteesPublicKeys,
+ trustedOrganizationPublicKeys,
+ );
+ }
+ override verifyTrust(user: Account): Promise<{
+ wasTrustDenied: boolean;
+ trustedOrganizationPublicKeys: Uint8Array[];
+ trustedEmergencyAccessUserPublicKeys: Uint8Array[];
+ }> {
+ return super.verifyTrust(user);
+ }
+ override getAccountDataRequest(
+ originalUserKey: UserKey,
+ newUnencryptedUserKey: UserKey,
+ user: Account,
+ ): Promise {
+ return super.getAccountDataRequest(originalUserKey, newUnencryptedUserKey, user);
+ }
+ override makeNewUserKeyV1(oldUserKey: UserKey): Promise {
+ return super.makeNewUserKeyV1(oldUserKey);
+ }
+ override makeNewUserKeyV2(
+ oldUserKey: UserKey,
+ ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> {
+ return super.makeNewUserKeyV2(oldUserKey);
+ }
+ override isV1User(userKey: UserKey): boolean {
+ return super.isV1User(userKey);
+ }
+ override isUserWithMasterPassword(id: UserId): boolean {
+ return super.isUserWithMasterPassword(id);
+ }
+ override makeServerMasterKeyAuthenticationHash(
+ masterPassword: string,
+ masterKeyKdfConfig: KdfConfig,
+ masterKeySalt: string,
+ ): Promise {
+ return super.makeServerMasterKeyAuthenticationHash(
+ masterPassword,
+ masterKeyKdfConfig,
+ masterKeySalt,
+ );
+ }
+}
+
+describe("KeyRotationService", () => {
+ let keyRotationService: TestUserKeyRotationService;
+
+ let mockApiService: MockProxy;
+ let mockCipherService: MockProxy;
+ let mockFolderService: MockProxy;
+ let mockSendService: MockProxy;
+ let mockEmergencyAccessService: MockProxy;
+ let mockResetPasswordService: MockProxy;
+ let mockDeviceTrustService: MockProxy;
+ let mockKeyService: MockProxy;
+ let mockEncryptService: MockProxy;
+ let mockConfigService: MockProxy;
+ let mockSyncService: MockProxy;
+ let mockWebauthnLoginAdminService: MockProxy;
+ let mockLogService: MockProxy;
+ let mockVaultTimeoutService: MockProxy;
+ let mockDialogService: MockProxy;
+ let mockToastService: MockProxy;
+ let mockI18nService: MockProxy;
+ let mockCryptoFunctionService: MockProxy;
+ let mockKdfConfigService: MockProxy;
+
+ const mockUser = {
+ id: "mockUserId" as UserId,
+ email: "mockEmail",
+ emailVerified: true,
+ name: "mockName",
+ };
+
+ const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
+
+ beforeAll(() => {
+ mockApiService = mock();
+ mockCipherService = mock();
+ mockFolderService = mock();
+ mockSendService = mock();
+ mockEmergencyAccessService = mock();
+ mockEmergencyAccessService.getPublicKeys.mockResolvedValue(
+ mockTrustedPublicKeys.map((key) => {
+ return {
+ publicKey: key,
+ id: "mockId",
+ granteeId: "mockGranteeId",
+ name: "mockName",
+ email: "mockEmail",
+ type: EmergencyAccessType.Takeover,
+ status: EmergencyAccessStatusType.Accepted,
+ waitTimeDays: 5,
+ creationDate: "mockCreationDate",
+ avatarColor: "mockAvatarColor",
+ };
+ }),
+ );
+ mockResetPasswordService = mock();
+ mockResetPasswordService.getPublicKeys.mockResolvedValue(
+ mockTrustedPublicKeys.map((key) => {
+ return {
+ publicKey: key,
+ orgId: "mockOrgId",
+ orgName: "mockOrgName",
+ };
+ }),
+ );
+ mockDeviceTrustService = mock();
+ mockKeyService = mock();
+ mockEncryptService = mock();
+ mockConfigService = mock();
+ mockSyncService = mock();
+ mockWebauthnLoginAdminService = mock();
+ mockLogService = mock();
+ mockVaultTimeoutService = mock();
+ mockToastService = mock();
+ mockI18nService = mock();
+ mockDialogService = mock();
+ mockCryptoFunctionService = mock();
+ mockKdfConfigService = mock();
+
+ keyRotationService = new TestUserKeyRotationService(
+ mockApiService,
+ mockCipherService,
+ mockFolderService,
+ mockSendService,
+ mockEmergencyAccessService,
+ mockResetPasswordService,
+ mockDeviceTrustService,
+ mockKeyService,
+ mockEncryptService,
+ mockSyncService,
+ mockWebauthnLoginAdminService,
+ mockLogService,
+ mockVaultTimeoutService,
+ mockToastService,
+ mockI18nService,
+ mockDialogService,
+ mockConfigService,
+ mockCryptoFunctionService,
+ mockKdfConfigService,
+ );
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mock("@bitwarden/key-management-ui");
+ jest.spyOn(PureCrypto, "make_user_key_aes256_cbc_hmac").mockReturnValue(new Uint8Array(64));
+ jest.spyOn(PureCrypto, "make_user_key_xchacha20_poly1305").mockReturnValue(new Uint8Array(70));
+ jest
+ .spyOn(PureCrypto, "encrypt_user_key_with_master_password")
+ .mockReturnValue("mockNewUserKey");
+ });
+
+ describe("rotateUserKeyAndEncryptedData", () => {
+ let privateKey: BehaviorSubject;
+ let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>;
+
+ beforeEach(() => {
+ mockSyncService.getLastSync.mockResolvedValue(new Date());
+ mockKeyService.makeUserKey.mockResolvedValue([
+ new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
+ {
+ encryptedString: "mockNewUserKey",
+ } as any,
+ ]);
+ mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
+ mockConfigService.getFeatureFlag.mockResolvedValue(false);
+
+ mockEncryptService.wrapSymmetricKey.mockResolvedValue({
+ encryptedString: "mockEncryptedData",
+ } as any);
+ mockEncryptService.wrapDecapsulationKey.mockResolvedValue({
+ encryptedString: "mockEncryptedData",
+ } as any);
+
+ // Mock user key
+ mockKeyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
+
+ mockKeyService.getFingerprint.mockResolvedValue(["a", "b"]);
+
+ // Mock private key
+ privateKey = new BehaviorSubject("mockPrivateKey" as any);
+ mockKeyService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey);
+
+ keyPair = new BehaviorSubject({
+ privateKey: "mockPrivateKey",
+ publicKey: "mockPublicKey",
+ } as any);
+ mockKeyService.userEncryptionKeyPair$.mockReturnValue(keyPair);
+
+ // Mock ciphers
+ const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
+ mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
+
+ // Mock folders
+ const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
+ mockFolderService.getRotatedData.mockResolvedValue(mockFolders);
+
+ // Mock sends
+ const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
+ mockSendService.getRotatedData.mockResolvedValue(mockSends);
+
+ // Mock emergency access
+ const emergencyAccess = [createMockEmergencyAccess("13")];
+ mockEmergencyAccessService.getRotatedData.mockResolvedValue(emergencyAccess);
+
+ // Mock reset password
+ const resetPassword = [createMockResetPassword("12")];
+ mockResetPasswordService.getRotatedData.mockResolvedValue(resetPassword);
+
+ // Mock Webauthn
+ const webauthn = [createMockWebauthn("13"), createMockWebauthn("14")];
+ mockWebauthnLoginAdminService.getRotatedData.mockResolvedValue(webauthn);
+
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
+ });
+
+ it("rotates the userkey and encrypted data and changes master password", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ AccountRecoveryTrustComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ mockKdfConfigService.getKdfConfig$.mockReturnValue(
+ new BehaviorSubject(new PBKDF2KdfConfig(100000)),
+ );
+ mockKeyService.userKey$.mockReturnValue(
+ new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey),
+ );
+ mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
+ mockKeyService.userEncryptedPrivateKey$.mockReturnValue(
+ new BehaviorSubject(
+ "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=" as EncryptedString,
+ ),
+ );
+ await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "mockMasterPassword1",
+ mockUser,
+ "masterPasswordHint",
+ );
+ const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0];
+ expect(arg.oldMasterKeyAuthenticationHash).toBe("mockMasterPasswordHash");
+ expect(arg.accountData.ciphers.length).toBe(2);
+ expect(arg.accountData.folders.length).toBe(2);
+ expect(arg.accountData.sends.length).toBe(2);
+ expect(arg.accountUnlockData.emergencyAccessUnlockData.length).toBe(1);
+ expect(arg.accountUnlockData.organizationAccountRecoveryUnlockData.length).toBe(1);
+ expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2);
+ });
+
+ it("throws if kdf config is null", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ mockKdfConfigService.getKdfConfig$.mockReturnValue(new BehaviorSubject(null));
+ await expect(
+ keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "mockMasterPassword1",
+ mockUser,
+ ),
+ ).rejects.toThrow();
+ });
+
+ it("returns early when emergency access trust warning dialog is declined", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenUntrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
+ await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "newMasterPassword",
+ mockUser,
+ );
+ expect(mockApiService.postUserKeyUpdate).not.toHaveBeenCalled();
+ });
+
+ it("returns early when account recovery trust warning dialog is declined", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenUntrusted;
+ await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "newMasterPassword",
+ mockUser,
+ );
+ expect(mockApiService.postUserKeyUpdate).not.toHaveBeenCalled();
+ });
+
+ it("throws if master password provided is falsey", async () => {
+ await expect(
+ keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData("", "", mockUser),
+ ).rejects.toThrow();
+ });
+
+ it("throws if no private key is found", async () => {
+ keyPair.next(null);
+
+ await expect(
+ keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "mockMasterPassword1",
+ mockUser,
+ ),
+ ).rejects.toThrow();
+ });
+
+ it("throws if server rotation fails", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
+ mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));
+
+ await expect(
+ keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "mockMasterPassword1",
+ mockUser,
+ ),
+ ).rejects.toThrow();
+ });
+ });
+
+ describe("getNewAccountKeysV1", () => {
+ const currentUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const mockEncryptedPrivateKey = new EncString(
+ "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=",
+ );
+ const mockNewEncryptedPrivateKey = new EncString(
+ "2.ab465OrUcluL9UpnCOUTAg==|4HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=",
+ );
+ beforeAll(() => {
+ mockEncryptService.unwrapDecapsulationKey.mockResolvedValue(new Uint8Array(200));
+ mockEncryptService.wrapDecapsulationKey.mockResolvedValue(mockNewEncryptedPrivateKey);
+ mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(new Uint8Array(400));
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("returns new account keys", async () => {
+ const result = await keyRotationService.getNewAccountKeysV1(
+ currentUserKey,
+ mockEncryptedPrivateKey,
+ );
+ expect(result).toEqual({
+ userKey: expect.any(SymmetricCryptoKey),
+ asymmetricEncryptionKeys: {
+ wrappedPrivateKey: mockNewEncryptedPrivateKey,
+ publicKey: Utils.fromBufferToB64(new Uint8Array(400)),
+ },
+ });
+ });
+ });
+
+ describe("getNewAccountKeysV2", () => {
+ it("throws not supported", async () => {
+ await expect(
+ keyRotationService.getNewAccountKeysV2(
+ new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
+ null,
+ ),
+ ).rejects.toThrow("User encryption v2 upgrade is not supported yet");
+ });
+ });
+
+ describe("createMasterPasswordUnlockData", () => {
+ it("returns the master password unlock data", async () => {
+ mockKeyService.makeMasterKey.mockResolvedValue(
+ new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
+ );
+ mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
+ const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const userAccount = mockUser;
+ const masterPasswordUnlockData =
+ await keyRotationService.createMasterPasswordUnlockDataRequest(newKey, {
+ masterPassword: "mockMasterPassword",
+ masterKeySalt: userAccount.email,
+ masterKeyKdfConfig: new PBKDF2KdfConfig(600_000),
+ masterPasswordHint: "mockMasterPasswordHint",
+ });
+ expect(masterPasswordUnlockData).toEqual({
+ masterKeyEncryptedUserKey: "mockNewUserKey",
+ email: "mockEmail",
+ kdfType: 0,
+ kdfIterations: 600_000,
+ masterKeyAuthenticationHash: "mockMasterPasswordHash",
+ masterPasswordHint: "mockMasterPasswordHint",
+ });
+ expect(PureCrypto.encrypt_user_key_with_master_password).toHaveBeenCalledWith(
+ new SymmetricCryptoKey(new Uint8Array(64)).toEncoded(),
+ "mockMasterPassword",
+ userAccount.email,
+ new PBKDF2KdfConfig(600_000).toSdkConfig(),
+ );
+ });
+ });
+
+ describe("getAccountUnlockDataRequest", () => {
+ it("returns the account unlock data request", async () => {
+ mockWebauthnLoginAdminService.getRotatedData.mockResolvedValue([
+ {
+ id: "mockId",
+ encryptedPublicKey: "mockEncryptedPublicKey" as any,
+ encryptedUserKey: "mockEncryptedUserKey" as any,
+ },
+ ]);
+ mockDeviceTrustService.getRotatedData.mockResolvedValue([
+ {
+ deviceId: "mockId",
+ encryptedPublicKey: "mockEncryptedPublicKey",
+ encryptedUserKey: "mockEncryptedUserKey",
+ },
+ ]);
+ mockEmergencyAccessService.getRotatedData.mockResolvedValue([
+ {
+ waitTimeDays: 5,
+ keyEncrypted: "mockEncryptedUserKey",
+ id: "mockId",
+ type: EmergencyAccessType.Takeover,
+ },
+ ]);
+ mockResetPasswordService.getRotatedData.mockResolvedValue([
+ {
+ organizationId: "mockOrgId",
+ resetPasswordKey: "mockEncryptedUserKey",
+ masterPasswordHash: "omitted",
+ otp: undefined,
+ authRequestAccessCode: undefined,
+ },
+ ]);
+ mockKeyService.makeMasterKey.mockResolvedValue(
+ new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
+ );
+ mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
+
+ const initialKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const userAccount = mockUser;
+ const accountUnlockDataRequest = await keyRotationService.getAccountUnlockDataRequest(
+ userAccount.id,
+ initialKey,
+ newKey,
+ {
+ masterPassword: "mockMasterPassword",
+ masterKeySalt: userAccount.email,
+ masterKeyKdfConfig: new PBKDF2KdfConfig(600_000),
+ masterPasswordHint: "mockMasterPasswordHint",
+ },
+ [new Uint8Array(1)], // emergency access public key
+ [new Uint8Array(2)], // account recovery public key
+ );
+ expect(accountUnlockDataRequest.passkeyUnlockData).toEqual([
+ {
+ encryptedPublicKey: "mockEncryptedPublicKey",
+ encryptedUserKey: "mockEncryptedUserKey",
+ id: "mockId",
+ },
+ ]);
+ expect(accountUnlockDataRequest.deviceKeyUnlockData).toEqual([
+ {
+ encryptedPublicKey: "mockEncryptedPublicKey",
+ encryptedUserKey: "mockEncryptedUserKey",
+ deviceId: "mockId",
+ },
+ ]);
+ expect(accountUnlockDataRequest.masterPasswordUnlockData).toEqual({
+ masterKeyEncryptedUserKey: "mockNewUserKey",
+ email: "mockEmail",
+ kdfType: 0,
+ kdfIterations: 600_000,
+ masterKeyAuthenticationHash: "mockMasterPasswordHash",
+ masterPasswordHint: "mockMasterPasswordHint",
+ });
+ expect(accountUnlockDataRequest.emergencyAccessUnlockData).toEqual([
+ {
+ keyEncrypted: "mockEncryptedUserKey",
+ id: "mockId",
+ type: EmergencyAccessType.Takeover,
+ waitTimeDays: 5,
+ },
+ ]);
+ expect(accountUnlockDataRequest.organizationAccountRecoveryUnlockData).toEqual([
+ {
+ organizationId: "mockOrgId",
+ resetPasswordKey: "mockEncryptedUserKey",
+ masterPasswordHash: "omitted",
+ otp: undefined,
+ authRequestAccessCode: undefined,
+ },
+ ]);
+ });
+ });
+
+ describe("verifyTrust", () => {
+ const mockGranteeEmergencyAccessWithPublicKey = {
+ publicKey: new Uint8Array(123),
+ id: "mockId",
+ granteeId: "mockGranteeId",
+ name: "mockName",
+ email: "mockEmail",
+ type: EmergencyAccessType.Takeover,
+ status: EmergencyAccessStatusType.Accepted,
+ waitTimeDays: 5,
+ creationDate: "mockCreationDate",
+ avatarColor: "mockAvatarColor",
+ };
+ const mockOrganizationUserResetPasswordEntry = {
+ publicKey: new Uint8Array(123),
+ orgId: "mockOrgId",
+ orgName: "mockOrgName",
+ };
+
+ it("returns empty arrays if initial dialog is closed", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenFalse;
+ mockEmergencyAccessService.getPublicKeys.mockResolvedValue([
+ mockGranteeEmergencyAccessWithPublicKey,
+ ]);
+ mockResetPasswordService.getPublicKeys.mockResolvedValue([
+ mockOrganizationUserResetPasswordEntry,
+ ]);
+ const {
+ wasTrustDenied,
+ trustedOrganizationPublicKeys: trustedOrgs,
+ trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
+ } = await keyRotationService.verifyTrust(mockUser);
+ expect(trustedEmergencyAccessUsers).toEqual([]);
+ expect(trustedOrgs).toEqual([]);
+ expect(wasTrustDenied).toBe(true);
+ });
+
+ it("returns empty arrays if emergency access dialog is closed", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ AccountRecoveryTrustComponent.open = initialPromptedOpenFalse;
+ mockEmergencyAccessService.getPublicKeys.mockResolvedValue([
+ mockGranteeEmergencyAccessWithPublicKey,
+ ]);
+ mockResetPasswordService.getPublicKeys.mockResolvedValue([
+ mockOrganizationUserResetPasswordEntry,
+ ]);
+ const {
+ wasTrustDenied,
+ trustedOrganizationPublicKeys: trustedOrgs,
+ trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
+ } = await keyRotationService.verifyTrust(mockUser);
+ expect(trustedEmergencyAccessUsers).toEqual([]);
+ expect(trustedOrgs).toEqual([]);
+ expect(wasTrustDenied).toBe(true);
+ });
+
+ it("returns empty arrays if account recovery dialog is closed", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ AccountRecoveryTrustComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = initialPromptedOpenFalse;
+ mockEmergencyAccessService.getPublicKeys.mockResolvedValue([
+ mockGranteeEmergencyAccessWithPublicKey,
+ ]);
+ mockResetPasswordService.getPublicKeys.mockResolvedValue([
+ mockOrganizationUserResetPasswordEntry,
+ ]);
+ const {
+ wasTrustDenied,
+ trustedOrganizationPublicKeys: trustedOrgs,
+ trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
+ } = await keyRotationService.verifyTrust(mockUser);
+ expect(trustedEmergencyAccessUsers).toEqual([]);
+ expect(trustedOrgs).toEqual([]);
+ expect(wasTrustDenied).toBe(true);
+ });
+
+ it("returns trusted keys if all dialogs are accepted", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
+ mockEmergencyAccessService.getPublicKeys.mockResolvedValue([
+ mockGranteeEmergencyAccessWithPublicKey,
+ ]);
+ mockResetPasswordService.getPublicKeys.mockResolvedValue([
+ mockOrganizationUserResetPasswordEntry,
+ ]);
+ const {
+ wasTrustDenied,
+ trustedOrganizationPublicKeys: trustedOrgs,
+ trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
+ } = await keyRotationService.verifyTrust(mockUser);
+ expect(wasTrustDenied).toBe(false);
+ expect(trustedEmergencyAccessUsers).toEqual([
+ mockGranteeEmergencyAccessWithPublicKey.publicKey,
+ ]);
+ expect(trustedOrgs).toEqual([mockOrganizationUserResetPasswordEntry.publicKey]);
+ });
+ });
+
+ describe("makeNewUserKeyV1", () => {
+ it("throws if old keys is xchacha20poly1305 key", async () => {
+ await expect(
+ keyRotationService.makeNewUserKeyV1(new SymmetricCryptoKey(new Uint8Array(70)) as UserKey),
+ ).rejects.toThrow(
+ "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.",
+ );
+ });
+ it("returns new user key", async () => {
+ const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const newKey = await keyRotationService.makeNewUserKeyV1(oldKey);
+ expect(newKey).toEqual(new SymmetricCryptoKey(new Uint8Array(64)));
+ });
+ });
+
+ describe("makeNewUserKeyV2", () => {
+ it("returns xchacha20poly1305 key", async () => {
+ const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey;
+ const { newUserKey } = await keyRotationService.makeNewUserKeyV2(oldKey);
+ expect(newUserKey).toEqual(new SymmetricCryptoKey(new Uint8Array(70)));
+ });
+ it("returns isUpgrading true if old key is v1", async () => {
+ const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const newKey = await keyRotationService.makeNewUserKeyV2(oldKey);
+ expect(newKey).toEqual({
+ newUserKey: new SymmetricCryptoKey(new Uint8Array(70)),
+ isUpgrading: true,
+ });
+ });
+ it("returns isUpgrading false if old key is v2", async () => {
+ const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey;
+ const newKey = await keyRotationService.makeNewUserKeyV2(oldKey);
+ expect(newKey).toEqual({
+ newUserKey: new SymmetricCryptoKey(new Uint8Array(70)),
+ isUpgrading: false,
+ });
+ });
+ });
+
+ describe("getAccountDataRequest", () => {
+ const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
+ const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")];
+ const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")];
+
+ it("returns the account data request", async () => {
+ const initialKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const userAccount = mockUser;
+
+ mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
+ mockFolderService.getRotatedData.mockResolvedValue(mockFolders);
+ mockSendService.getRotatedData.mockResolvedValue(mockSends);
+
+ const accountDataRequest = await keyRotationService.getAccountDataRequest(
+ initialKey,
+ newKey,
+ userAccount,
+ );
+ expect(accountDataRequest).toEqual({
+ ciphers: mockCiphers,
+ folders: mockFolders,
+ sends: mockSends,
+ });
+ });
+
+ it("throws if rotated ciphers are null", async () => {
+ const initialKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const userAccount = mockUser;
+
+ mockCipherService.getRotatedData.mockResolvedValue(null);
+ mockFolderService.getRotatedData.mockResolvedValue(mockFolders);
+ mockSendService.getRotatedData.mockResolvedValue(mockSends);
+
+ await expect(
+ keyRotationService.getAccountDataRequest(initialKey, newKey, userAccount),
+ ).rejects.toThrow();
+ });
+
+ it("throws if rotated folders are null", async () => {
+ const initialKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const userAccount = mockUser;
+
+ mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
+ mockFolderService.getRotatedData.mockResolvedValue(null);
+ mockSendService.getRotatedData.mockResolvedValue(mockSends);
+
+ await expect(
+ keyRotationService.getAccountDataRequest(initialKey, newKey, userAccount),
+ ).rejects.toThrow();
+ });
+
+ it("throws if rotated sends are null", async () => {
+ const initialKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const userAccount = mockUser;
+
+ mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
+ mockFolderService.getRotatedData.mockResolvedValue(mockFolders);
+ mockSendService.getRotatedData.mockResolvedValue(null);
+
+ await expect(
+ keyRotationService.getAccountDataRequest(initialKey, newKey, userAccount),
+ ).rejects.toThrow();
+ });
+ });
+
+ describe("isV1UserKey", () => {
+ const v1Key = new SymmetricCryptoKey(new Uint8Array(64));
+ const v2Key = new SymmetricCryptoKey(new Uint8Array(70));
+ it("returns true for v1 key", () => {
+ expect(keyRotationService.isV1User(v1Key as UserKey)).toBe(true);
+ });
+ it("returns false for v2 key", () => {
+ expect(keyRotationService.isV1User(v2Key as UserKey)).toBe(false);
+ });
+ });
+});
diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
index fc4ad0c869b..051c32d97e4 100644
--- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
+++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
@@ -1,27 +1,27 @@
import { Injectable } from "@angular/core";
-import { firstValueFrom } from "rxjs";
+import { firstValueFrom, Observable } from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
-import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
-import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
+import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
-import { HashPurpose } from "@bitwarden/common/platform/enums";
+import { EncryptionType, HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
+import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
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 { DialogService, ToastService } from "@bitwarden/components";
-import { KeyService } from "@bitwarden/key-management";
+import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
import {
AccountRecoveryTrustComponent,
EmergencyAccessTrustComponent,
@@ -40,10 +40,16 @@ import { UnlockDataRequest } from "./request/unlock-data.request";
import { UserDataRequest } from "./request/userdata.request";
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
-@Injectable()
+type MasterPasswordAuthenticationAndUnlockData = {
+ masterPassword: string;
+ masterKeySalt: string;
+ masterKeyKdfConfig: KdfConfig;
+ masterPasswordHint: string;
+};
+
+@Injectable({ providedIn: "root" })
export class UserKeyRotationService {
constructor(
- private userVerificationService: UserVerificationService,
private apiService: UserKeyRotationApiService,
private cipherService: CipherService,
private folderService: FolderService,
@@ -61,118 +67,345 @@ export class UserKeyRotationService {
private i18nService: I18nService,
private dialogService: DialogService,
private configService: ConfigService,
+ private cryptoFunctionService: CryptoFunctionService,
+ private kdfConfigService: KdfConfigService,
) {}
/**
* Creates a new user key and re-encrypts all required data with the it.
- * @param oldMasterPassword: The current master password
+ * @param currentMasterPassword: The current master password
* @param newMasterPassword: The new master password
* @param user: The user account
* @param newMasterPasswordHint: The hint for the new master password
*/
async rotateUserKeyMasterPasswordAndEncryptedData(
- oldMasterPassword: string,
+ currentMasterPassword: string,
newMasterPassword: string,
user: Account,
newMasterPasswordHint?: string,
): Promise {
- this.logService.info("[Userkey rotation] Starting user key rotation...");
- if (!newMasterPassword) {
- this.logService.info("[Userkey rotation] Invalid master password provided. Aborting!");
- throw new Error("Invalid master password");
+ this.logService.info("[UserKey Rotation] Starting user key rotation...");
+
+ const upgradeToV2FeatureFlagEnabled = await this.configService.getFeatureFlag(
+ FeatureFlag.EnrollAeadOnKeyRotation,
+ );
+
+ // Make sure all conditions match - e.g. account state is up to date
+ await this.ensureIsAllowedToRotateUserKey();
+
+ // First, the provided organizations and emergency access users need to be verified;
+ // this is currently done by providing the user a manual confirmation dialog.
+ const { wasTrustDenied, trustedOrganizationPublicKeys, trustedEmergencyAccessUserPublicKeys } =
+ await this.verifyTrust(user);
+ if (wasTrustDenied) {
+ this.logService.info("[Userkey rotation] Trust was denied by user. Aborting!");
+ return;
}
+ // Read current cryptographic state / settings
+ const masterKeyKdfConfig: KdfConfig = (await this.firstValueFromOrThrow(
+ this.kdfConfigService.getKdfConfig$(user.id),
+ "KDF config",
+ ))!;
+ // The masterkey salt used for deriving the masterkey always needs to be trimmed and lowercased.
+ const masterKeySalt = user.email.trim().toLowerCase();
+ const currentUserKey: UserKey = (await this.firstValueFromOrThrow(
+ this.keyService.userKey$(user.id),
+ "User key",
+ ))!;
+ const currentUserKeyWrappedPrivateKey = new EncString(
+ (await this.firstValueFromOrThrow(
+ this.keyService.userEncryptedPrivateKey$(user.id),
+ "User encrypted private key",
+ ))!,
+ );
+
+ // Update account keys
+ // This creates at least a new user key, and possibly upgrades user encryption formats
+ let newUserKey: UserKey;
+ let wrappedPrivateKey: EncString;
+ let publicKey: string;
+ if (upgradeToV2FeatureFlagEnabled) {
+ this.logService.info("[Userkey rotation] Using v2 account keys");
+ const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV2(
+ currentUserKey,
+ currentUserKeyWrappedPrivateKey,
+ );
+ newUserKey = userKey;
+ wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey;
+ publicKey = asymmetricEncryptionKeys.publicKey;
+ } else {
+ this.logService.info("[Userkey rotation] Using v1 account keys");
+ const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV1(
+ currentUserKey,
+ currentUserKeyWrappedPrivateKey,
+ );
+ newUserKey = userKey;
+ wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey;
+ publicKey = asymmetricEncryptionKeys.publicKey;
+ }
+
+ // Assemble the key rotation request
+ const request = new RotateUserAccountKeysRequest(
+ await this.getAccountUnlockDataRequest(
+ user.id,
+ currentUserKey,
+ newUserKey,
+ {
+ masterPassword: newMasterPassword,
+ masterKeyKdfConfig,
+ masterKeySalt,
+ masterPasswordHint: newMasterPasswordHint,
+ } as MasterPasswordAuthenticationAndUnlockData,
+ trustedEmergencyAccessUserPublicKeys,
+ trustedOrganizationPublicKeys,
+ ),
+ new AccountKeysRequest(wrappedPrivateKey.encryptedString!, publicKey),
+ await this.getAccountDataRequest(currentUserKey, newUserKey, user),
+ await this.makeServerMasterKeyAuthenticationHash(
+ currentMasterPassword,
+ masterKeyKdfConfig,
+ masterKeySalt,
+ ),
+ );
+
+ this.logService.info("[Userkey rotation] Posting user key rotation request to server");
+ await this.apiService.postUserKeyUpdate(request);
+ this.logService.info("[Userkey rotation] Userkey rotation request posted to server");
+
+ this.toastService.showToast({
+ variant: "success",
+ title: this.i18nService.t("rotationCompletedTitle"),
+ message: this.i18nService.t("rotationCompletedDesc"),
+ timeout: 15000,
+ });
+
+ // temporary until userkey can be better verified
+ await this.vaultTimeoutService.logOut();
+ }
+
+ protected async ensureIsAllowedToRotateUserKey(): Promise {
if ((await this.syncService.getLastSync()) === null) {
this.logService.info("[Userkey rotation] Client was never synced. Aborting!");
throw new Error(
"The local vault is de-synced and the keys cannot be rotated. Please log out and log back in to resolve this issue.",
);
}
+ }
+ protected async getNewAccountKeysV1(
+ currentUserKey: UserKey,
+ currentUserKeyWrappedPrivateKey: EncString,
+ ): Promise<{
+ userKey: UserKey;
+ asymmetricEncryptionKeys: {
+ wrappedPrivateKey: EncString;
+ publicKey: string;
+ };
+ }> {
+ // Account key rotation creates a new userkey. All downstream data and keys need to be re-encrypted under this key.
+ // Further, this method is used to create new keys in the event that the key hierarchy changes, such as for the
+ // creation of a new signing key pair.
+ const newUserKey = await this.makeNewUserKeyV1(currentUserKey);
+
+ // Re-encrypt the private key with the new user key
+ // Rotation of the private key is not supported yet
+ const privateKey = await this.encryptService.unwrapDecapsulationKey(
+ currentUserKeyWrappedPrivateKey,
+ currentUserKey,
+ );
+ const newUserKeyWrappedPrivateKey = await this.encryptService.wrapDecapsulationKey(
+ privateKey,
+ newUserKey,
+ );
+ const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
+
+ return {
+ userKey: newUserKey,
+ asymmetricEncryptionKeys: {
+ wrappedPrivateKey: newUserKeyWrappedPrivateKey,
+ publicKey: Utils.fromBufferToB64(publicKey),
+ },
+ };
+ }
+
+ protected async getNewAccountKeysV2(
+ currentUserKey: UserKey,
+ currentUserKeyWrappedPrivateKey: EncString,
+ ): Promise<{
+ userKey: UserKey;
+ asymmetricEncryptionKeys: {
+ wrappedPrivateKey: EncString;
+ publicKey: string;
+ };
+ }> {
+ throw new Error("User encryption v2 upgrade is not supported yet");
+ }
+
+ protected async createMasterPasswordUnlockDataRequest(
+ userKey: UserKey,
+ newUnlockData: MasterPasswordAuthenticationAndUnlockData,
+ ): Promise {
+ // Decryption via stretched-masterkey-wrapped-userkey
+ const newMasterKeyEncryptedUserKey = new EncString(
+ PureCrypto.encrypt_user_key_with_master_password(
+ userKey.toEncoded(),
+ newUnlockData.masterPassword,
+ newUnlockData.masterKeySalt,
+ newUnlockData.masterKeyKdfConfig.toSdkConfig(),
+ ),
+ );
+
+ const newMasterKeyAuthenticationHash = await this.makeServerMasterKeyAuthenticationHash(
+ newUnlockData.masterPassword,
+ newUnlockData.masterKeyKdfConfig,
+ newUnlockData.masterKeySalt,
+ );
+
+ return new MasterPasswordUnlockDataRequest(
+ newUnlockData.masterKeyKdfConfig,
+ newUnlockData.masterKeySalt,
+ newMasterKeyAuthenticationHash,
+ newMasterKeyEncryptedUserKey.encryptedString!,
+ newUnlockData.masterPasswordHint,
+ );
+ }
+
+ protected async getAccountUnlockDataRequest(
+ userId: UserId,
+ currentUserKey: UserKey,
+ newUserKey: UserKey,
+ masterPasswordAuthenticationAndUnlockData: MasterPasswordAuthenticationAndUnlockData,
+ trustedEmergencyAccessGranteesPublicKeys: Uint8Array[],
+ trustedOrganizationPublicKeys: Uint8Array[],
+ ): Promise {
+ // To ensure access; all unlock methods need to be updated and provided the new user key.
+ // User unlock methods
+ let masterPasswordUnlockData: MasterPasswordUnlockDataRequest;
+ if (this.isUserWithMasterPassword(userId)) {
+ masterPasswordUnlockData = await this.createMasterPasswordUnlockDataRequest(
+ newUserKey,
+ masterPasswordAuthenticationAndUnlockData,
+ );
+ }
+ const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData(
+ currentUserKey,
+ newUserKey,
+ userId,
+ );
+ const trustedDeviceUnlockData = await this.deviceTrustService.getRotatedData(
+ currentUserKey,
+ newUserKey,
+ userId,
+ );
+
+ // Unlock methods that share to a different user / group
+ const emergencyAccessUnlockData = await this.emergencyAccessService.getRotatedData(
+ newUserKey,
+ trustedEmergencyAccessGranteesPublicKeys,
+ userId,
+ );
+ const organizationAccountRecoveryUnlockData = (await this.resetPasswordService.getRotatedData(
+ newUserKey,
+ trustedOrganizationPublicKeys,
+ userId,
+ ))!;
+
+ return new UnlockDataRequest(
+ masterPasswordUnlockData!,
+ emergencyAccessUnlockData,
+ organizationAccountRecoveryUnlockData,
+ passkeyUnlockData,
+ trustedDeviceUnlockData,
+ );
+ }
+
+ protected async verifyTrust(user: Account): Promise<{
+ wasTrustDenied: boolean;
+ trustedOrganizationPublicKeys: Uint8Array[];
+ trustedEmergencyAccessUserPublicKeys: Uint8Array[];
+ }> {
+ // Since currently the joined organizations and emergency access grantees are
+ // not signed, manual trust prompts are required, to verify that the server
+ // does not inject public keys here.
+ //
+ // Once signing is implemented, this is the place to also sign the keys and
+ // upload the signed trust claims.
+ //
+ // The flow works in 3 steps:
+ // 1. Prepare the user by showing them a dialog telling them they'll be asked
+ // to verify the trust of their organizations and emergency access users.
+ // 2. Show the user a dialog for each organization and ask them to verify the trust.
+ // 3. Show the user a dialog for each emergency access user and ask them to verify the trust.
+
+ this.logService.info("[Userkey rotation] Verifying trust...");
const emergencyAccessGrantees = await this.emergencyAccessService.getPublicKeys();
- const orgs = await this.resetPasswordService.getPublicKeys(user.id);
- if (orgs.length > 0 || emergencyAccessGrantees.length > 0) {
+ const organizations = await this.resetPasswordService.getPublicKeys(user.id);
+
+ if (organizations.length > 0 || emergencyAccessGrantees.length > 0) {
const trustInfoDialog = KeyRotationTrustInfoComponent.open(this.dialogService, {
numberOfEmergencyAccessUsers: emergencyAccessGrantees.length,
- orgName: orgs.length > 0 ? orgs[0].orgName : undefined,
+ orgName: organizations.length > 0 ? organizations[0].orgName : undefined,
});
- const result = await firstValueFrom(trustInfoDialog.closed);
- if (!result) {
- this.logService.info("[Userkey rotation] Trust info dialog closed. Aborting!");
- return;
+ if (!(await firstValueFrom(trustInfoDialog.closed))) {
+ return {
+ wasTrustDenied: true,
+ trustedOrganizationPublicKeys: [],
+ trustedEmergencyAccessUserPublicKeys: [],
+ };
}
}
- const {
- masterKey: oldMasterKey,
- email,
- kdfConfig,
- } = await this.userVerificationService.verifyUserByMasterPassword(
- {
- type: VerificationType.MasterPassword,
- secret: oldMasterPassword,
- },
- user.id,
- user.email,
- );
-
- const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig);
-
- let userKeyBytes: Uint8Array;
- if (await this.configService.getFeatureFlag(FeatureFlag.EnrollAeadOnKeyRotation)) {
- userKeyBytes = PureCrypto.make_user_key_xchacha20_poly1305();
- } else {
- userKeyBytes = PureCrypto.make_user_key_aes256_cbc_hmac();
+ for (const organization of organizations) {
+ const dialogRef = AccountRecoveryTrustComponent.open(this.dialogService, {
+ name: organization.orgName,
+ orgId: organization.orgId,
+ publicKey: organization.publicKey,
+ });
+ if (!(await firstValueFrom(dialogRef.closed))) {
+ return {
+ wasTrustDenied: true,
+ trustedOrganizationPublicKeys: [],
+ trustedEmergencyAccessUserPublicKeys: [],
+ };
+ }
}
- const newMasterKeyEncryptedUserKey = new EncString(
- PureCrypto.encrypt_user_key_with_master_password(
- userKeyBytes,
- newMasterPassword,
- email,
- kdfConfig.toSdkConfig(),
- ),
- );
- const newUnencryptedUserKey = new SymmetricCryptoKey(userKeyBytes) as UserKey;
-
- if (!newUnencryptedUserKey || !newMasterKeyEncryptedUserKey) {
- this.logService.info("[Userkey rotation] User key could not be created. Aborting!");
- throw new Error("User key could not be created");
+ for (const details of emergencyAccessGrantees) {
+ const dialogRef = EmergencyAccessTrustComponent.open(this.dialogService, {
+ name: details.name,
+ userId: details.granteeId,
+ publicKey: details.publicKey,
+ });
+ if (!(await firstValueFrom(dialogRef.closed))) {
+ return {
+ wasTrustDenied: true,
+ trustedOrganizationPublicKeys: [],
+ trustedEmergencyAccessUserPublicKeys: [],
+ };
+ }
}
- const newMasterKeyAuthenticationHash = await this.keyService.hashMasterKey(
- newMasterPassword,
- newMasterKey,
- HashPurpose.ServerAuthorization,
- );
- const masterPasswordUnlockData = new MasterPasswordUnlockDataRequest(
- kdfConfig,
- email,
- newMasterKeyAuthenticationHash,
- newMasterKeyEncryptedUserKey.encryptedString!,
- newMasterPasswordHint,
+ this.logService.info(
+ "[Userkey rotation] Trust verified for all organizations and emergency access users",
);
+ return {
+ wasTrustDenied: false,
+ trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey),
+ trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map((d) => d.publicKey),
+ };
+ }
- const keyPair = await firstValueFrom(this.keyService.userEncryptionKeyPair$(user.id));
- if (keyPair == null) {
- this.logService.info("[Userkey rotation] Key pair is null. Aborting!");
- throw new Error("Key pair is null");
- }
- const { privateKey, publicKey } = keyPair;
-
- const accountKeysRequest = new AccountKeysRequest(
- (
- await this.encryptService.wrapDecapsulationKey(privateKey, newUnencryptedUserKey)
- ).encryptedString!,
- Utils.fromBufferToB64(publicKey),
- );
-
- const originalUserKey = await firstValueFrom(this.keyService.userKey$(user.id));
- if (originalUserKey == null) {
- this.logService.info("[Userkey rotation] Userkey is null. Aborting!");
- throw new Error("Userkey key is null");
- }
+ protected async getAccountDataRequest(
+ originalUserKey: UserKey,
+ newUnencryptedUserKey: UserKey,
+ user: Account,
+ ): Promise {
+ // Account data is any data owned by the user; this is folders, ciphers (and their attachments), and sends.
+ // Currently, ciphers, folders and sends are directly encrypted with the user key. This means
+ // that they need to be re-encrypted and re-uploaded. In the future, content-encryption keys
+ // (such as cipher keys) will make it so only re-encrypted keys are required.
const rotatedCiphers = await this.cipherService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
@@ -192,111 +425,102 @@ export class UserKeyRotationService {
this.logService.info("[Userkey rotation] ciphers, folders, or sends are null. Aborting!");
throw new Error("ciphers, folders, or sends are null");
}
- const accountDataRequest = new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
+ return new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
+ }
- for (const details of emergencyAccessGrantees) {
- this.logService.info("[Userkey rotation] Emergency access grantee: " + details.name);
+ protected async makeNewUserKeyV1(oldUserKey: UserKey): Promise {
+ // The user's account format is determined by the user key.
+ // Being tied to the userkey ensures an all-or-nothing approach. A compromised
+ // server cannot downgrade to a previous format (no signing keys) without
+ // completely making the account unusable.
+ //
+ // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts)
+ // This format is unsupported, and not secure; It is being forced migrated, and being removed
+ // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025)
+ // This format is still supported, but may be migrated in the future
+ // V2: XChaCha20-Poly1305 userkey, signing key, account security version
+ // This is the new, modern format.
+ if (this.isV1User(oldUserKey)) {
this.logService.info(
- "[Userkey rotation] Emergency access grantee fingerprint: " +
- (await this.keyService.getFingerprint(details.granteeId, details.publicKey)).join("-"),
+ "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; not upgrading",
+ );
+ return new SymmetricCryptoKey(PureCrypto.make_user_key_aes256_cbc_hmac()) as UserKey;
+ } else {
+ // If the feature flag is rolled back, we want to block rotation in order to be as safe as possible with the user's account.
+ this.logService.info(
+ "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305, but feature flag is not enabled; aborting..",
+ );
+ throw new Error(
+ "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.",
);
-
- const dialogRef = EmergencyAccessTrustComponent.open(this.dialogService, {
- name: details.name,
- userId: details.granteeId,
- publicKey: details.publicKey,
- });
- const result = await firstValueFrom(dialogRef.closed);
- if (result === true) {
- this.logService.info("[Userkey rotation] Emergency access grantee confirmed");
- } else {
- this.logService.info("[Userkey rotation] Emergency access grantee not confirmed");
- return;
- }
}
- const trustedUserPublicKeys = emergencyAccessGrantees.map((d) => d.publicKey);
+ }
- const emergencyAccessUnlockData = await this.emergencyAccessService.getRotatedData(
- newUnencryptedUserKey,
- trustedUserPublicKeys,
- user.id,
- );
-
- for (const organization of orgs) {
+ protected async makeNewUserKeyV2(
+ oldUserKey: UserKey,
+ ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> {
+ // The user's account format is determined by the user key.
+ // Being tied to the userkey ensures an all-or-nothing approach. A compromised
+ // server cannot downgrade to a previous format (no signing keys) without
+ // completely making the account unusable.
+ //
+ // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts)
+ // This format is unsupported, and not secure; It is being forced migrated, and being removed
+ // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025)
+ // This format is still supported, but may be migrated in the future
+ // V2: XChaCha20-Poly1305 userkey, signing key, account security version
+ // This is the new, modern format.
+ const newUserKey: UserKey = new SymmetricCryptoKey(
+ PureCrypto.make_user_key_xchacha20_poly1305(),
+ ) as UserKey;
+ const isUpgrading = this.isV1User(oldUserKey);
+ if (isUpgrading) {
this.logService.info(
- "[Userkey rotation] Reset password organization: " + organization.orgName,
+ "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; upgrading to XChaCha20-Poly1305",
);
+ } else {
this.logService.info(
- "[Userkey rotation] Trusted organization public key: " + organization.publicKey,
+ "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305; no upgrade needed",
);
- const fingerprint = await this.keyService.getFingerprint(
- organization.orgId,
- organization.publicKey,
- );
- this.logService.info(
- "[Userkey rotation] Trusted organization fingerprint: " + fingerprint.join("-"),
- );
-
- const dialogRef = AccountRecoveryTrustComponent.open(this.dialogService, {
- name: organization.orgName,
- orgId: organization.orgId,
- publicKey: organization.publicKey,
- });
- const result = await firstValueFrom(dialogRef.closed);
- if (result === true) {
- this.logService.info("[Userkey rotation] Organization trusted");
- } else {
- this.logService.info("[Userkey rotation] Organization not trusted");
- return;
- }
}
- const trustedOrgPublicKeys = orgs.map((d) => d.publicKey);
- // Note: Reset password keys request model has user verification
- // properties, but the rotation endpoint uses its own MP hash.
- const organizationAccountRecoveryUnlockData = (await this.resetPasswordService.getRotatedData(
- newUnencryptedUserKey,
- trustedOrgPublicKeys,
- user.id,
- ))!;
- const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData(
- originalUserKey,
- newUnencryptedUserKey,
- user.id,
+ return { isUpgrading, newUserKey };
+ }
+
+ /**
+ * A V1 user has no signing key, and uses AES256-CBC-HMAC.
+ * A V2 user has a signing key, and uses XChaCha20-Poly1305.
+ */
+ protected isV1User(userKey: UserKey): boolean {
+ return userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64;
+ }
+
+ protected isUserWithMasterPassword(id: UserId): boolean {
+ // Currently, key rotation can only be activated when the user has a master password.
+ return true;
+ }
+
+ protected async makeServerMasterKeyAuthenticationHash(
+ masterPassword: string,
+ masterKeyKdfConfig: KdfConfig,
+ masterKeySalt: string,
+ ): Promise {
+ const masterKey = await this.keyService.makeMasterKey(
+ masterPassword,
+ masterKeySalt,
+ masterKeyKdfConfig,
);
-
- const trustedDeviceUnlockData = await this.deviceTrustService.getRotatedData(
- originalUserKey,
- newUnencryptedUserKey,
- user.id,
+ return this.keyService.hashMasterKey(
+ masterPassword,
+ masterKey,
+ HashPurpose.ServerAuthorization,
);
+ }
- const unlockDataRequest = new UnlockDataRequest(
- masterPasswordUnlockData,
- emergencyAccessUnlockData,
- organizationAccountRecoveryUnlockData,
- passkeyUnlockData,
- trustedDeviceUnlockData,
- );
-
- const request = new RotateUserAccountKeysRequest(
- unlockDataRequest,
- accountKeysRequest,
- accountDataRequest,
- await this.keyService.hashMasterKey(oldMasterPassword, oldMasterKey),
- );
-
- this.logService.info("[Userkey rotation] Posting user key rotation request to server");
- await this.apiService.postUserKeyUpdateV2(request);
- this.logService.info("[Userkey rotation] Userkey rotation request posted to server");
-
- this.toastService.showToast({
- variant: "success",
- title: this.i18nService.t("rotationCompletedTitle"),
- message: this.i18nService.t("rotationCompletedDesc"),
- timeout: 15000,
- });
-
- // temporary until userkey can be better verified
- await this.vaultTimeoutService.logOut();
+ async firstValueFromOrThrow(value: Observable, name: string): Promise {
+ const result = await firstValueFrom(value);
+ if (result == null) {
+ throw new Error(`Failed to get ${name}`);
+ }
+ return result;
}
}
diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts
index d1ab7689cfe..443b3e03e5f 100644
--- a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts
+++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts
@@ -22,7 +22,6 @@ import { SmLandingApiService } from "./sm-landing-api.service";
@Component({
selector: "app-request-sm-access",
- standalone: true,
templateUrl: "request-sm-access.component.html",
imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule, OssModule],
})
diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts
index 4d9dceab34a..301e6f7dfad 100644
--- a/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts
+++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts
@@ -14,7 +14,6 @@ import { SharedModule } from "../../shared/shared.module";
@Component({
selector: "app-sm-landing",
- standalone: true,
imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule],
templateUrl: "sm-landing.component.html",
})
diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
index 0ca2ea86bf6..9679f0879b9 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
+++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
@@ -298,8 +298,11 @@ export class VaultItemsComponent {
protected canAssignCollections(cipher: CipherView) {
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
+ const editableCollections = this.allCollections.filter((c) => !c.readOnly);
+
return (
- (organization?.canEditAllCiphers && this.viewingOrgVault) || cipher.canAssignToCollections
+ (organization?.canEditAllCiphers && this.viewingOrgVault) ||
+ (cipher.canAssignToCollections && editableCollections.length > 0)
);
}
diff --git a/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts b/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts
index 407ae007622..6498d358c44 100644
--- a/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts
+++ b/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts
@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { Observable } from "rxjs";
-import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
+import { OtherDeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
import { EncString } from "../../../platform/models/domain/enc-string";
@@ -61,5 +61,5 @@ export abstract class DeviceTrustServiceAbstraction {
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
- ) => Promise;
+ ) => Promise;
}
diff --git a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts
index ecfeb10dcda..f3fb9547366 100644
--- a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts
+++ b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts
@@ -200,7 +200,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
oldUserKey: UserKey,
newUserKey: UserKey,
userId: UserId,
- ): Promise {
+ ): Promise {
if (!userId) {
throw new Error("UserId is required. Cannot get rotated data.");
}
diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts
index b17e85ca9c4..5ce7e37778d 100644
--- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts
+++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts
@@ -417,16 +417,12 @@ describe("VaultTimeoutService", () => {
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
});
- it("should call locked callback if no user passed into lock", async () => {
+ it("should call locked callback with the locking user if no userID is passed in.", async () => {
setupLock();
await vaultTimeoutService.lock();
- // Currently these pass `undefined` (or what they were given) as the userId back
- // but we could change this to give the user that was locked (active) to these methods
- // so they don't have to get it their own way, but that is a behavioral change that needs
- // to be tested.
- expect(lockedCallback).toHaveBeenCalledWith(undefined);
+ expect(lockedCallback).toHaveBeenCalledWith("user1");
});
it("should call state event runner with user passed into lock", async () => {
diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts
index 131f826fd33..04769567db2 100644
--- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts
+++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts
@@ -49,7 +49,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private taskSchedulerService: TaskSchedulerService,
protected logService: LogService,
private biometricService: BiometricsService,
- private lockedCallback: (userId?: string) => Promise = null,
+ private lockedCallback: (userId: UserId) => Promise = null,
private loggedOutCallback: (
logoutReason: LogoutReason,
userId?: string,
@@ -166,7 +166,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
this.messagingService.send("locked", { userId: lockingUserId });
if (this.lockedCallback != null) {
- await this.lockedCallback(userId);
+ await this.lockedCallback(lockingUserId);
}
}
diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts
index 4a3fca16515..965c6858470 100644
--- a/libs/key-management/src/abstractions/key.service.ts
+++ b/libs/key-management/src/abstractions/key.service.ts
@@ -167,8 +167,9 @@ export abstract class KeyService {
* Clears the user's stored version of the user key
* @param keySuffix The desired version of the key to clear
* @param userId The desired user
+ * @throws Error when userId is null or undefined.
*/
- abstract clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: string): Promise;
+ abstract clearStoredUserKey(keySuffix: KeySuffixOptions, userId: string): Promise;
/**
* Stores the master key encrypted user key
* @throws Error when userId is null and there is no active user.
diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts
index 7d30af23372..1fc998dc131 100644
--- a/libs/key-management/src/key.service.spec.ts
+++ b/libs/key-management/src/key.service.spec.ts
@@ -410,6 +410,45 @@ describe("keyService", () => {
});
});
+ describe("clearStoredUserKey", () => {
+ describe("input validation", () => {
+ const invalidUserIdTestCases = [
+ { keySuffix: KeySuffixOptions.Auto, userId: null as unknown as UserId },
+ { keySuffix: KeySuffixOptions.Auto, userId: undefined as unknown as UserId },
+ { keySuffix: KeySuffixOptions.Pin, userId: null as unknown as UserId },
+ { keySuffix: KeySuffixOptions.Pin, userId: undefined as unknown as UserId },
+ ];
+ test.each(invalidUserIdTestCases)(
+ "throws when keySuffix is $keySuffix and userId is $userId",
+ async ({ keySuffix, userId }) => {
+ await expect(keyService.clearStoredUserKey(keySuffix, userId)).rejects.toThrow(
+ "UserId is required",
+ );
+ },
+ );
+ });
+
+ describe("with Auto key suffix", () => {
+ it("UserKeyAutoUnlock is cleared and pin keys are not cleared", async () => {
+ await keyService.clearStoredUserKey(KeySuffixOptions.Auto, mockUserId);
+
+ expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, {
+ userId: mockUserId,
+ });
+ expect(pinService.clearPinKeyEncryptedUserKeyEphemeral).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("with PIN key suffix", () => {
+ it("pin keys are cleared and user key auto unlock not", async () => {
+ await keyService.clearStoredUserKey(KeySuffixOptions.Pin, mockUserId);
+
+ expect(stateService.setUserKeyAutoUnlock).not.toHaveBeenCalled();
+ expect(pinService.clearPinKeyEncryptedUserKeyEphemeral).toHaveBeenCalledWith(mockUserId);
+ });
+ });
+ });
+
describe("clearKeys", () => {
test.each([null as unknown as UserId, undefined as unknown as UserId])(
"throws when the provided userId is %s",
diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts
index 6cbb1fbcc03..a872e89cb82 100644
--- a/libs/key-management/src/key.service.ts
+++ b/libs/key-management/src/key.service.ts
@@ -250,16 +250,16 @@ export class DefaultKeyService implements KeyServiceAbstraction {
await this.clearAllStoredUserKeys(userId);
}
- async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise {
- if (keySuffix === KeySuffixOptions.Auto) {
- // 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.stateService.setUserKeyAutoUnlock(null, { userId: userId });
+ async clearStoredUserKey(keySuffix: KeySuffixOptions, userId: UserId): Promise {
+ if (userId == null) {
+ throw new Error("UserId is required");
}
- if (keySuffix === KeySuffixOptions.Pin && userId != null) {
- // 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.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId);
+
+ if (keySuffix === KeySuffixOptions.Auto) {
+ await this.stateService.setUserKeyAutoUnlock(null, { userId: userId });
+ }
+ if (keySuffix === KeySuffixOptions.Pin) {
+ await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId);
}
}