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/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."); }