From b3065546756c9671252782affa207e4c25d717e4 Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Thu, 20 Jun 2024 11:36:24 -0400 Subject: [PATCH] [PM-6789] finish key rotation distribution and fix legacy user (#9498) * finish key rotation distribution and fix legacy user * add ticket to TODO * PR feedback: docs and renaming * fix webauthn tests * add test for send service * add await to test --- ...zation-user-reset-password.service.spec.ts | 17 +- ...rganization-user-reset-password.service.ts | 13 +- .../webauthn-login-admin.service.spec.ts | 8 +- .../webauthn-login-admin.service.ts | 11 +- .../services/emergency-access.service.spec.ts | 23 +- .../services/emergency-access.service.ts | 20 +- .../user-key-rotation.service.spec.ts | 203 ++++++++++-------- .../key-rotation/user-key-rotation.service.ts | 136 +++++++----- .../migrate-legacy-encryption.component.ts | 9 +- .../settings/change-password.component.ts | 3 +- libs/auth/src/common/abstractions/index.ts | 1 + ...-key-rotation-data-provider.abstraction.ts | 23 ++ .../platform/abstractions/crypto.service.ts | 9 + .../src/platform/services/crypto.service.ts | 6 +- .../send/services/send.service.abstraction.ts | 11 +- .../tools/send/services/send.service.spec.ts | 24 ++- .../src/tools/send/services/send.service.ts | 12 +- .../src/vault/abstractions/cipher.service.ts | 20 +- .../folder/folder.service.abstraction.ts | 20 +- .../src/vault/services/cipher.service.spec.ts | 66 +++++- .../src/vault/services/cipher.service.ts | 29 +++ .../services/folder/folder.service.spec.ts | 23 ++ .../vault/services/folder/folder.service.ts | 25 +++ 23 files changed, 516 insertions(+), 196 deletions(-) create mode 100644 libs/auth/src/common/abstractions/user-key-rotation-data-provider.abstraction.ts diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts index 6cdd5ed894..637373b936 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts @@ -13,6 +13,7 @@ import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key"; import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service"; @@ -157,7 +158,7 @@ describe("OrganizationUserResetPasswordService", () => { }); }); - describe("getRotatedKeys", () => { + describe("getRotatedData", () => { beforeEach(() => { organizationService.getAll.mockResolvedValue([ createOrganization("1", "org1"), @@ -175,12 +176,24 @@ describe("OrganizationUserResetPasswordService", () => { }); it("should return all re-encrypted account recovery keys", async () => { - const result = await sut.getRotatedKeys( + const result = await sut.getRotatedData( new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, + new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, + "mockUserId" as UserId, ); expect(result).toHaveLength(2); }); + + it("throws if the new user key is null", async () => { + await expect( + sut.getRotatedData( + new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, + null, + "mockUserId" as UserId, + ), + ).rejects.toThrow("New user key is required for rotation."); + }); }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts index c029d2ecdb..860fa6abc4 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts @@ -1,5 +1,6 @@ import { Injectable } from "@angular/core"; +import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; @@ -19,12 +20,15 @@ import { KdfType } from "@bitwarden/common/platform/enums"; 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 { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; @Injectable({ providedIn: "root", }) -export class OrganizationUserResetPasswordService { +export class OrganizationUserResetPasswordService + implements UserKeyRotationDataProvider +{ constructor( private cryptoService: CryptoService, private encryptService: EncryptService, @@ -129,11 +133,16 @@ export class OrganizationUserResetPasswordService { /** * Returns existing account recovery keys re-encrypted with the new user key. + * @param originalUserKey the original user key * @param newUserKey the new user key + * @param userId the user id * @throws Error if new user key is null + * @returns a list of account recovery keys that have been re-encrypted with the new user key */ - async getRotatedKeys( + async getRotatedData( + originalUserKey: UserKey, newUserKey: UserKey, + userId: UserId, ): Promise { if (newUserKey == null) { throw new Error("New user key is required for rotation."); diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts index 4ad712b935..83e8c890f5 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts @@ -190,7 +190,7 @@ describe("WebauthnAdminService", () => { it("should throw when old userkey is null", async () => { const newUserKey = makeSymmetricCryptoKey(64) as UserKey; try { - await service.rotateWebAuthnKeys(null, newUserKey); + await service.getRotatedData(null, newUserKey, null); } catch (error) { expect(error).toEqual(new Error("oldUserKey is required")); } @@ -198,7 +198,7 @@ describe("WebauthnAdminService", () => { it("should throw when new userkey is null", async () => { const oldUserKey = makeSymmetricCryptoKey(64) as UserKey; try { - await service.rotateWebAuthnKeys(oldUserKey, null); + await service.getRotatedData(oldUserKey, null, null); } catch (error) { expect(error).toEqual(new Error("newUserKey is required")); } @@ -222,7 +222,7 @@ describe("WebauthnAdminService", () => { .mockResolvedValue( new RotateableKeySet(mockEncryptedUserKey, mockEncryptedPublicKey), ); - await service.rotateWebAuthnKeys(oldUserKey, newUserKey); + await service.getRotatedData(oldUserKey, newUserKey, null); expect(rotateKeySetMock).toHaveBeenCalledWith( expect.any(RotateableKeySet), oldUserKey, @@ -242,7 +242,7 @@ describe("WebauthnAdminService", () => { ], } as any); const rotateKeySetMock = jest.spyOn(rotateableKeySetService, "rotateKeySet"); - await service.rotateWebAuthnKeys(oldUserKey, newUserKey); + await service.getRotatedData(oldUserKey, newUserKey, null); expect(rotateKeySetMock).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts index fadf5cbd29..1b04614e54 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts @@ -1,7 +1,7 @@ import { Injectable, Optional } from "@angular/core"; import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs"; -import { PrfKeySet } from "@bitwarden/auth/common"; +import { PrfKeySet, UserKeyRotationDataProvider } from "@bitwarden/auth/common"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction"; import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; @@ -9,6 +9,7 @@ import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/a import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; @@ -25,7 +26,9 @@ import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service /** * Service for managing WebAuthnLogin credentials. */ -export class WebauthnLoginAdminService { +export class WebauthnLoginAdminService + implements UserKeyRotationDataProvider +{ static readonly MaxCredentialCount = 5; private navigatorCredentials: CredentialsContainer; @@ -283,11 +286,13 @@ export class WebauthnLoginAdminService { * * @param oldUserKey The old user key * @param newUserKey The new user key + * @param userId The user id * @returns A promise that returns an array of rotate credential requests when resolved. */ - async rotateWebAuthnKeys( + async getRotatedData( oldUserKey: UserKey, newUserKey: UserKey, + userId: UserId, ): Promise { if (!oldUserKey) { throw new Error("oldUserKey is required"); diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts index 6de3fd9d8b..7906731d81 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts @@ -11,6 +11,7 @@ import { EncryptionType, KdfType } from "@bitwarden/common/platform/enums"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -223,8 +224,11 @@ describe("EmergencyAccessService", () => { }); }); - describe("getRotatedKeys", () => { - let mockUserKey: UserKey; + describe("getRotatedData", () => { + const mockRandomBytes = new Uint8Array(64) as CsprngArray; + const mockOriginalUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; + const mockNewUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; + const allowedStatuses = [ EmergencyAccessStatusType.Confirmed, EmergencyAccessStatusType.RecoveryInitiated, @@ -242,9 +246,6 @@ describe("EmergencyAccessService", () => { } as ListResponse; beforeEach(() => { - const mockRandomBytes = new Uint8Array(64) as CsprngArray; - mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; - emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue(mockEmergencyAccess); apiService.getUserPublicKey.mockResolvedValue({ userId: "mockUserId", @@ -259,10 +260,20 @@ describe("EmergencyAccessService", () => { }); it("Only returns emergency accesses with allowed statuses", async () => { - const result = await emergencyAccessService.getRotatedKeys(mockUserKey); + const result = await emergencyAccessService.getRotatedData( + mockOriginalUserKey, + mockNewUserKey, + "mockUserId" as UserId, + ); expect(result).toHaveLength(allowedStatuses.length); }); + + it("throws if new user key is null", async () => { + await expect( + emergencyAccessService.getRotatedData(mockOriginalUserKey, null, "mockUserId" as UserId), + ).rejects.toThrow("New user key is required for rotation."); + }); }); }); diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index a50a5adc6c..362b1dec3c 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -1,5 +1,6 @@ import { Injectable } from "@angular/core"; +import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; @@ -15,6 +16,7 @@ import { KdfType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -35,7 +37,9 @@ import { import { EmergencyAccessApiService } from "./emergency-access-api.service"; @Injectable() -export class EmergencyAccessService { +export class EmergencyAccessService + implements UserKeyRotationDataProvider +{ constructor( private emergencyAccessApiService: EmergencyAccessApiService, private apiService: ApiService, @@ -286,9 +290,21 @@ export class EmergencyAccessService { /** * Returns existing emergency access keys re-encrypted with new user key. * Intended for grantor. + * @param originalUserKey the original user key * @param newUserKey the new user key + * @param userId the user id + * @throws Error if newUserKey is nullish + * @returns an array of re-encrypted emergency access requests or an empty array if there are no requests */ - async getRotatedKeys(newUserKey: UserKey): Promise { + async getRotatedData( + originalUserKey: UserKey, + newUserKey: UserKey, + userId: UserId, + ): Promise { + if (newUserKey == null) { + throw new Error("New user key is required for rotation."); + } + const requests: EmergencyAccessWithIdRequest[] = []; const existingEmergencyAccess = await this.emergencyAccessApiService.getEmergencyAccessTrusted(); diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts index 5addcceabf..7241e50d35 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts @@ -1,37 +1,30 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, of } from "rxjs"; +import { BehaviorSubject } from "rxjs"; +import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; +import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { EncryptionType } 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 { Send } from "@bitwarden/common/tools/send/models/domain/send"; +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 } from "@bitwarden/common/types/key"; +import { UserKey, UserPrivateKey } 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 { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; -import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; -import { Folder } from "@bitwarden/common/vault/models/domain/folder"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { 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 { - FakeAccountService, - mockAccountServiceWith, -} from "../../../../../../libs/common/spec/fake-account-service"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; -import { StateService } from "../../core"; import { WebauthnLoginAdminService } from "../core"; import { EmergencyAccessService } from "../emergency-access"; +import { EmergencyAccessWithIdRequest } from "../emergency-access/request/emergency-access-update.request"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; import { UserKeyRotationService } from "./user-key-rotation.service"; @@ -48,16 +41,19 @@ describe("KeyRotationService", () => { let mockDeviceTrustService: MockProxy; let mockCryptoService: MockProxy; let mockEncryptService: MockProxy; - let mockStateService: MockProxy; let mockConfigService: MockProxy; let mockKdfConfigService: MockProxy; let mockSyncService: MockProxy; let mockWebauthnLoginAdminService: MockProxy; - - const mockUserId = Utils.newGuid() as UserId; - const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId); let mockMasterPasswordService: FakeMasterPasswordService = new FakeMasterPasswordService(); + const mockUser = { + id: "mockUserId" as UserId, + email: "mockEmail", + emailVerified: true, + name: "mockName", + }; + beforeAll(() => { mockMasterPasswordService = new FakeMasterPasswordService(); mockApiService = mock(); @@ -69,7 +65,6 @@ describe("KeyRotationService", () => { mockDeviceTrustService = mock(); mockCryptoService = mock(); mockEncryptService = mock(); - mockStateService = mock(); mockConfigService = mock(); mockKdfConfigService = mock(); mockSyncService = mock(); @@ -86,8 +81,6 @@ describe("KeyRotationService", () => { mockDeviceTrustService, mockCryptoService, mockEncryptService, - mockStateService, - mockAccountService, mockKdfConfigService, mockSyncService, mockWebauthnLoginAdminService, @@ -98,91 +91,82 @@ describe("KeyRotationService", () => { jest.clearAllMocks(); }); - it("instantiates", () => { - expect(keyRotationService).not.toBeFalsy(); - }); - describe("rotateUserKeyAndEncryptedData", () => { - let folderViews: BehaviorSubject; - let sends: BehaviorSubject; + let privateKey: BehaviorSubject; - beforeAll(() => { + beforeEach(() => { mockCryptoService.makeMasterKey.mockResolvedValue("mockMasterKey" as any); mockCryptoService.makeUserKey.mockResolvedValue([ new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, { - encryptedString: "mockEncryptedUserKey", + encryptedString: "mockNewUserKey", } as any, ]); mockCryptoService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash"); mockConfigService.getFeatureFlag.mockResolvedValue(true); - // Mock private key - mockCryptoService.getPrivateKey.mockResolvedValue("MockPrivateKey" as any); - mockCryptoService.userKey$.mockReturnValue( - of(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey), - ); - - // Mock ciphers - const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")]; - mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers); - - // Mock folders - const mockFolders = [createMockFolder("1", "Folder 1"), createMockFolder("2", "Folder 2")]; - folderViews = new BehaviorSubject(mockFolders); - mockFolderService.folderViews$ = folderViews; - - // Mock sends - const mockSends = [createMockSend("1", "Send 1"), createMockSend("2", "Send 2")]; - sends = new BehaviorSubject(mockSends); - mockSendService.sends$ = sends; - - mockWebauthnLoginAdminService.rotateWebAuthnKeys.mockResolvedValue([]); - - // Mock encryption methods mockEncryptService.encrypt.mockResolvedValue({ encryptedString: "mockEncryptedData", } as any); - mockFolderService.encrypt.mockImplementation((folder, userKey) => { - const encryptedFolder = new Folder(); - encryptedFolder.id = folder.id; - encryptedFolder.name = new EncString( - EncryptionType.AesCbc256_HmacSha256_B64, - "Encrypted: " + folder.name, - ); - return Promise.resolve(encryptedFolder); - }); + // Mock user key + mockCryptoService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any)); - mockCipherService.encrypt.mockImplementation((cipher, userKey) => { - const encryptedCipher = new Cipher(); - encryptedCipher.id = cipher.id; - encryptedCipher.name = new EncString( - EncryptionType.AesCbc256_HmacSha256_B64, - "Encrypted: " + cipher.name, - ); - return Promise.resolve(encryptedCipher); - }); + // Mock private key + privateKey = new BehaviorSubject("mockPrivateKey" as any); + mockCryptoService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey); + + // 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 user key and encrypted data", async () => { - await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"); + await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser); expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled(); const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0]; + expect(arg.key).toBe("mockNewUserKey"); + expect(arg.privateKey).toBe("mockEncryptedData"); expect(arg.ciphers.length).toBe(2); expect(arg.folders.length).toBe(2); + expect(arg.sends.length).toBe(2); + expect(arg.emergencyAccessKeys.length).toBe(1); + expect(arg.resetPasswordKeys.length).toBe(1); + expect(arg.webauthnKeys.length).toBe(2); }); it("throws if master password provided is falsey", async () => { - await expect(keyRotationService.rotateUserKeyAndEncryptedData("")).rejects.toThrow(); + await expect( + keyRotationService.rotateUserKeyAndEncryptedData("", mockUser), + ).rejects.toThrow(); }); it("throws if master key creation fails", async () => { mockCryptoService.makeMasterKey.mockResolvedValueOnce(null); await expect( - keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"), + keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser), ).rejects.toThrow(); }); @@ -190,16 +174,24 @@ describe("KeyRotationService", () => { mockCryptoService.makeUserKey.mockResolvedValueOnce([null, null]); await expect( - keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"), + keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser), + ).rejects.toThrow(); + }); + + it("throws if no private key is found", async () => { + privateKey.next(null); + + await expect( + keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser), ).rejects.toThrow(); }); it("saves the master key in state after creation", async () => { - await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"); + await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser); expect(mockMasterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( "mockMasterKey" as any, - mockUserId, + mockUser.id, ); }); @@ -207,30 +199,51 @@ describe("KeyRotationService", () => { mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError")); await expect( - keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"), + keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser), ).rejects.toThrow(); }); }); }); -function createMockFolder(id: string, name: string): FolderView { - const folder = new FolderView(); - folder.id = id; - folder.name = name; - return folder; +function createMockFolder(id: string, name: string): FolderWithIdRequest { + return { + id: id, + name: name, + } as FolderWithIdRequest; } -function createMockCipher(id: string, name: string): CipherView { - const cipher = new CipherView(); - cipher.id = id; - cipher.name = name; - cipher.type = CipherType.Login; - return cipher; +function createMockCipher(id: string, name: string): CipherWithIdRequest { + return { + id: id, + name: name, + type: CipherType.Login, + } as CipherWithIdRequest; } -function createMockSend(id: string, name: string): Send { - const send = new Send(); - send.id = id; - send.name = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, name); - return send; +function createMockSend(id: string, name: string): SendWithIdRequest { + return { + id: id, + name: name, + } as SendWithIdRequest; +} + +function createMockEmergencyAccess(id: string): EmergencyAccessWithIdRequest { + return { + id: id, + type: 0, + waitTimeDays: 5, + } as EmergencyAccessWithIdRequest; +} + +function createMockResetPassword(id: string): OrganizationUserResetPasswordWithIdRequest { + return { + organizationId: id, + resetPasswordKey: "mockResetPasswordKey", + } as OrganizationUserResetPasswordWithIdRequest; +} + +function createMockWebauthn(id: string): any { + return { + id: id, + } as WebauthnRotateCredentialRequest; } diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts index 883d0fef88..0453b10cba 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts @@ -1,21 +1,19 @@ import { Injectable } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; +import { firstValueFrom } from "rxjs"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; 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 { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request"; -import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { WebauthnLoginAdminService } from "../core"; @@ -37,8 +35,6 @@ export class UserKeyRotationService { private deviceTrustService: DeviceTrustServiceAbstraction, private cryptoService: CryptoService, private encryptService: EncryptService, - private stateService: StateService, - private accountService: AccountService, private kdfConfigService: KdfConfigService, private syncService: SyncService, private webauthnLoginAdminService: WebauthnLoginAdminService, @@ -48,7 +44,10 @@ export class UserKeyRotationService { * Creates a new user key and re-encrypts all required data with the it. * @param masterPassword current master password (used for validation) */ - async rotateUserKeyAndEncryptedData(masterPassword: string): Promise { + async rotateUserKeyAndEncryptedData( + masterPassword: string, + user: { id: UserId } & AccountInfo, + ): Promise { if (!masterPassword) { throw new Error("Invalid master password"); } @@ -62,7 +61,7 @@ export class UserKeyRotationService { // Create master key to validate the master password const masterKey = await this.cryptoService.makeMasterKey( masterPassword, - await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email))), + user.email, await this.kdfConfigService.getKdfConfig(), ); @@ -71,9 +70,7 @@ export class UserKeyRotationService { } // Set master key again in case it was lost (could be lost on refresh) - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - const oldUserKey = await firstValueFrom(this.cryptoService.userKey$(userId)); - await this.masterPasswordService.setMasterKey(masterKey, userId); + await this.masterPasswordService.setMasterKey(masterKey, user.id); const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(masterKey); if (!newUserKey || !newEncUserKey) { @@ -90,61 +87,86 @@ export class UserKeyRotationService { const masterPasswordHash = await this.cryptoService.hashMasterKey(masterPassword, masterKey); request.masterPasswordHash = masterPasswordHash; + // Get original user key + // Note: We distribute the legacy key, but not all domains actually use it. If any of those + // domains break their legacy support it will break the migration process for legacy users. + const originalUserKey = await this.cryptoService.getUserKeyWithLegacySupport(user.id); + // Add re-encrypted data - request.privateKey = await this.encryptPrivateKey(newUserKey); - request.ciphers = await this.encryptCiphers(newUserKey); - request.folders = await this.encryptFolders(newUserKey); - request.sends = await this.sendService.getRotatedKeys(newUserKey); - request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey); - request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey); - request.webauthnKeys = await this.webauthnLoginAdminService.rotateWebAuthnKeys( - oldUserKey, + request.privateKey = await this.encryptPrivateKey(newUserKey, user.id); + + const rotatedCiphers = await this.cipherService.getRotatedData( + originalUserKey, newUserKey, + user.id, ); + if (rotatedCiphers != null) { + request.ciphers = rotatedCiphers; + } + + const rotatedFolders = await this.folderService.getRotatedData( + originalUserKey, + newUserKey, + user.id, + ); + if (rotatedFolders != null) { + request.folders = rotatedFolders; + } + + const rotatedSends = await this.sendService.getRotatedData( + originalUserKey, + newUserKey, + user.id, + ); + if (rotatedSends != null) { + request.sends = rotatedSends; + } + + const rotatedEmergencyAccessKeys = await this.emergencyAccessService.getRotatedData( + originalUserKey, + newUserKey, + user.id, + ); + if (rotatedEmergencyAccessKeys != null) { + request.emergencyAccessKeys = rotatedEmergencyAccessKeys; + } + + // Note: Reset password keys request model has user verification + // properties, but the rotation endpoint uses its own MP hash. + const rotatedResetPasswordKeys = await this.resetPasswordService.getRotatedData( + originalUserKey, + newUserKey, + user.id, + ); + if (rotatedResetPasswordKeys != null) { + request.resetPasswordKeys = rotatedResetPasswordKeys; + } + + const rotatedWebauthnKeys = await this.webauthnLoginAdminService.getRotatedData( + originalUserKey, + newUserKey, + user.id, + ); + if (rotatedWebauthnKeys != null) { + request.webauthnKeys = rotatedWebauthnKeys; + } await this.apiService.postUserKeyUpdate(request); - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - await this.deviceTrustService.rotateDevicesTrust( - activeAccount.id, - newUserKey, - masterPasswordHash, - ); + // TODO PM-2199: Add device trust rotation support to the user key rotation endpoint + await this.deviceTrustService.rotateDevicesTrust(user.id, newUserKey, masterPasswordHash); } - private async encryptPrivateKey(newUserKey: UserKey): Promise { - const privateKey = await this.cryptoService.getPrivateKey(); + private async encryptPrivateKey( + newUserKey: UserKey, + userId: UserId, + ): Promise { + const privateKey = await firstValueFrom( + this.cryptoService.userPrivateKeyWithLegacySupport$(userId), + ); if (!privateKey) { - return; + throw new Error("No private key found for user key rotation"); } return (await this.encryptService.encrypt(privateKey, newUserKey)).encryptedString; } - - private async encryptCiphers(newUserKey: UserKey): Promise { - const ciphers = await this.cipherService.getAllDecrypted(); - if (!ciphers) { - // Must return an empty array for backwards compatibility - return []; - } - return await Promise.all( - ciphers.map(async (cipher) => { - const encryptedCipher = await this.cipherService.encrypt(cipher, newUserKey); - return new CipherWithIdRequest(encryptedCipher); - }), - ); - } - - private async encryptFolders(newUserKey: UserKey): Promise { - const folders = await firstValueFrom(this.folderService.folderViews$); - if (!folders) { - // Must return an empty array for backwards compatibility - return []; - } - return await Promise.all( - folders.map(async (folder) => { - const encryptedFolder = await this.folderService.encrypt(folder, newUserKey); - return new FolderWithIdRequest(encryptedFolder); - }), - ); - } } diff --git a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts b/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts index bcde8fb745..6695039307 100644 --- a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts +++ b/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts @@ -1,6 +1,8 @@ import { Component } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -25,6 +27,7 @@ export class MigrateFromLegacyEncryptionComponent { }); constructor( + private accountService: AccountService, private keyRotationService: UserKeyRotationService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, @@ -41,7 +44,9 @@ export class MigrateFromLegacyEncryptionComponent { return; } - const hasUserKey = await this.cryptoService.hasUserKey(); + const activeUser = await firstValueFrom(this.accountService.activeAccount$); + + const hasUserKey = await this.cryptoService.hasUserKey(activeUser.id); if (hasUserKey) { this.messagingService.send("logout"); throw new Error("User key already exists, cannot migrate legacy encryption."); @@ -52,7 +57,7 @@ export class MigrateFromLegacyEncryptionComponent { try { await this.syncService.fullSync(false, true); - await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword); + await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword, activeUser); this.platformUtilsService.showToast( "success", diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index 8c52e71bdd..95bdb70624 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -220,6 +220,7 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent { } private async updateKey() { - await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword); + const user = await firstValueFrom(this.accountService.activeAccount$); + await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword, user); } } diff --git a/libs/auth/src/common/abstractions/index.ts b/libs/auth/src/common/abstractions/index.ts index e686de5201..6b618992e9 100644 --- a/libs/auth/src/common/abstractions/index.ts +++ b/libs/auth/src/common/abstractions/index.ts @@ -3,3 +3,4 @@ export * from "./login-email.service"; export * from "./login-strategy.service"; export * from "./user-decryption-options.service.abstraction"; export * from "./auth-request.service.abstraction"; +export * from "./user-key-rotation-data-provider.abstraction"; diff --git a/libs/auth/src/common/abstractions/user-key-rotation-data-provider.abstraction.ts b/libs/auth/src/common/abstractions/user-key-rotation-data-provider.abstraction.ts new file mode 100644 index 0000000000..49fea91936 --- /dev/null +++ b/libs/auth/src/common/abstractions/user-key-rotation-data-provider.abstraction.ts @@ -0,0 +1,23 @@ +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; + +/** + * Constructs key rotation requests for data encryption by the user key. + * @typeparam TRequest A request model that contains re-encrypted data, must have an id property + */ +export interface UserKeyRotationDataProvider< + TRequest extends { id: string } | { organizationId: string }, +> { + /** + * Provides re-encrypted data for the user key rotation process + * @param originalUserKey The original user key, useful for decrypting data + * @param newUserKey The new user key to use for re-encryption + * @param userId The owner of the data, useful for fetching data + * @returns A list of data that has been re-encrypted with the new user key + */ + getRotatedData( + originalUserKey: UserKey, + newUserKey: UserKey, + userId: UserId, + ): Promise; +} diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts index c2ecec6a09..82fa56c32d 100644 --- a/libs/common/src/platform/abstractions/crypto.service.ts +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -269,6 +269,15 @@ export abstract class CryptoService { */ abstract userPrivateKey$(userId: UserId): Observable; + /** + * Gets an observable stream of the given users decrypted private key with legacy support, + * will emit null if the user doesn't have a UserKey to decrypt the encrypted private key + * or null if the user doesn't have an encrypted private key at all. + * + * @param userId The user id of the user to get the data for. + */ + abstract userPrivateKeyWithLegacySupport$(userId: UserId): Observable; + /** * Generates a fingerprint phrase for the user based on their public key * @param fingerprintMaterial Fingerprint material diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index bef5922a31..4d8a447576 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -929,6 +929,10 @@ export class CryptoService implements CryptoServiceAbstraction { return this.userPrivateKeyHelper$(userId, false).pipe(map((keys) => keys?.userPrivateKey)); } + userPrivateKeyWithLegacySupport$(userId: UserId): Observable { + return this.userPrivateKeyHelper$(userId, true).pipe(map((keys) => keys?.userPrivateKey)); + } + private userPrivateKeyHelper$(userId: UserId, legacySupport: boolean) { const userKey$ = legacySupport ? this.userKeyWithLegacySupport$(userId) : this.userKey$(userId); return userKey$.pipe( @@ -1010,7 +1014,7 @@ export class CryptoService implements CryptoServiceAbstraction { } orgKeys$(userId: UserId) { - return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys)); + return this.cipherDecryptionKeys$(userId, true).pipe(map((keys) => keys?.orgKeys)); } cipherDecryptionKeys$( diff --git a/libs/common/src/tools/send/services/send.service.abstraction.ts b/libs/common/src/tools/send/services/send.service.abstraction.ts index e9f9387169..6033c9c6cb 100644 --- a/libs/common/src/tools/send/services/send.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send.service.abstraction.ts @@ -1,14 +1,17 @@ import { Observable } from "rxjs"; +import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; + import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; -export abstract class SendService { +export abstract class SendService implements UserKeyRotationDataProvider { sends$: Observable; sendViews$: Observable; @@ -31,7 +34,11 @@ export abstract class SendService { * @throws Error if the new user key is null or undefined * @returns A list of user sends that have been re-encrypted with the new user key */ - getRotatedKeys: (newUserKey: UserKey) => Promise; + getRotatedData: ( + originalUserKey: UserKey, + newUserKey: UserKey, + userId: UserId, + ) => Promise; /** * @deprecated Do not call this, use the sends$ observable collection */ diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 2f0f50c616..9ed5bed014 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -400,8 +400,11 @@ describe("SendService", () => { expect(sends[0]).toMatchObject(testSendViewData("1", "Test Send")); }); - describe("getRotatedKeys", () => { + describe("getRotatedData", () => { + const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; let encryptedKey: EncString; + beforeEach(() => { encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); encryptedKey = new EncString("Re-encrypted Send Key"); @@ -409,27 +412,30 @@ describe("SendService", () => { }); it("returns re-encrypted user sends", async () => { - const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; - const result = await sendService.getRotatedKeys(newUserKey); + const result = await sendService.getRotatedData(originalUserKey, newUserKey, mockUserId); expect(result).toMatchObject([{ id: "1", key: "Re-encrypted Send Key" }]); }); - it("returns null if there are no sends", async () => { - // 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 - sendService.replace(null); + it("returns empty array if there are no sends", async () => { + await sendService.replace(null); await awaitAsync(); const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; - const result = await sendService.getRotatedKeys(newUserKey); + const result = await sendService.getRotatedData(originalUserKey, newUserKey, mockUserId); expect(result).toEqual([]); }); + it("throws if the original user key is null", async () => { + await expect(sendService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow( + "Original user key is required for rotation.", + ); + }); + it("throws if the new user key is null", async () => { - await expect(sendService.getRotatedKeys(null)).rejects.toThrowError( + await expect(sendService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow( "New user key is required for rotation.", ); }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index aad0b887c8..7048cf5a37 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -9,6 +9,7 @@ import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; @@ -258,12 +259,17 @@ export class SendService implements InternalSendServiceAbstraction { await this.stateProvider.setEncryptedSends(sends); } - async getRotatedKeys(newUserKey: UserKey): Promise { + async getRotatedData( + originalUserKey: UserKey, + newUserKey: UserKey, + userId: UserId, + ): Promise { if (newUserKey == null) { throw new Error("New user key is required for rotation."); } - - const originalUserKey = await this.cryptoService.getUserKey(); + if (originalUserKey == null) { + throw new Error("Original user key is required for rotation."); + } const req = await firstValueFrom( this.sends$.pipe( diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 2c0676f644..2c8af23ba3 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -1,19 +1,22 @@ import { Observable } from "rxjs"; +import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; import { LocalData } from "@bitwarden/common/vault/models/data/local.data"; import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; -import { CipherId, CollectionId, OrganizationId } from "../../types/guid"; +import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid"; +import { UserKey } from "../../types/key"; import { CipherType } from "../enums/cipher-type"; import { CipherData } from "../models/data/cipher.data"; import { Cipher } from "../models/domain/cipher"; import { Field } from "../models/domain/field"; +import { CipherWithIdRequest } from "../models/request/cipher-with-id.request"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; -export abstract class CipherService { +export abstract class CipherService implements UserKeyRotationDataProvider { cipherViews$: Observable>; ciphers$: Observable>; localData$: Observable>; @@ -146,4 +149,17 @@ export abstract class CipherService { restoreManyWithServer: (ids: string[], orgId?: string) => Promise; getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise; setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise; + /** + * Returns user ciphers re-encrypted with the new user key. + * @param originalUserKey the original user key + * @param newUserKey the new user key + * @param userId the user id + * @throws Error if new user key is null + * @returns a list of user ciphers that have been re-encrypted with the new user key + */ + getRotatedData: ( + originalUserKey: UserKey, + newUserKey: UserKey, + userId: UserId, + ) => Promise; } diff --git a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts index 65e75e1376..1d88e2d96d 100644 --- a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts @@ -1,11 +1,16 @@ import { Observable } from "rxjs"; +import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; + import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { UserId } from "../../../types/guid"; +import { UserKey } from "../../../types/key"; import { FolderData } from "../../models/data/folder.data"; import { Folder } from "../../models/domain/folder"; +import { FolderWithIdRequest } from "../../models/request/folder-with-id.request"; import { FolderView } from "../../models/view/folder.view"; -export abstract class FolderService { +export abstract class FolderService implements UserKeyRotationDataProvider { folders$: Observable; folderViews$: Observable; @@ -22,6 +27,19 @@ export abstract class FolderService { */ getAllDecryptedFromState: () => Promise; decryptFolders: (folders: Folder[]) => Promise; + /** + * Returns user folders re-encrypted with the new user key. + * @param originalUserKey the original user key + * @param newUserKey the new user key + * @param userId the user id + * @throws Error if new user key is null + * @returns a list of user folders that have been re-encrypted with the new user key + */ + getRotatedData: ( + originalUserKey: UserKey, + newUserKey: UserKey, + userId: UserId, + ) => Promise; } export abstract class InternalFolderService extends FolderService { diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 9b03753118..ba85f51c38 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1,5 +1,5 @@ import { mock } from "jest-mock-extended"; -import { of } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeStateProvider } from "../../../spec/fake-state-provider"; @@ -10,7 +10,7 @@ import { AutofillSettingsService } from "../../autofill/services/autofill-settin import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; import { UriMatchStrategy } from "../../models/domain/domain-service"; import { ConfigService } from "../../platform/abstractions/config/config.service"; -import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { CipherDecryptionKeys, CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { StateService } from "../../platform/abstractions/state.service"; @@ -19,8 +19,8 @@ import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; import { EncString } from "../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../platform/services/container.service"; -import { UserId } from "../../types/guid"; -import { CipherKey, OrgKey } from "../../types/key"; +import { CipherId, UserId } from "../../types/guid"; +import { CipherKey, OrgKey, UserKey } from "../../types/key"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { FieldType } from "../enums"; import { CipherRepromptType } from "../enums/cipher-reprompt-type"; @@ -331,6 +331,64 @@ describe("Cipher Service", () => { }); }); }); + + describe("getRotatedData", () => { + const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + let decryptedCiphers: BehaviorSubject>; + let encryptedKey: EncString; + + beforeEach(() => { + setEncryptionKeyFlag(true); + configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true)); + + searchService.indexedEntityId$ = of(null); + stateService.getUserId.mockResolvedValue(mockUserId); + + const keys = { + userKey: originalUserKey, + } as CipherDecryptionKeys; + cryptoService.cipherDecryptionKeys$.mockReturnValue(of(keys)); + + const cipher1 = new CipherView(cipherObj); + cipher1.id = "Cipher 1"; + const cipher2 = new CipherView(cipherObj); + cipher2.id = "Cipher 2"; + + decryptedCiphers = new BehaviorSubject({ + Cipher1: cipher1, + Cipher2: cipher2, + }); + cipherService.cipherViews$ = decryptedCiphers; + + encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); + encryptedKey = new EncString("Re-encrypted Cipher Key"); + encryptService.encrypt.mockResolvedValue(encryptedKey); + + cryptoService.makeCipherKey.mockResolvedValue( + new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey, + ); + }); + + it("returns re-encrypted user ciphers", async () => { + const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId); + + expect(result[0]).toMatchObject({ id: "Cipher 1", key: "Re-encrypted Cipher Key" }); + expect(result[1]).toMatchObject({ id: "Cipher 2", key: "Re-encrypted Cipher Key" }); + }); + + it("throws if the original user key is null", async () => { + await expect(cipherService.getRotatedData(null, newUserKey, mockUserId)).rejects.toThrow( + "Original user key is required to rotate ciphers", + ); + }); + + it("throws if the new user key is null", async () => { + await expect(cipherService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow( + "New user key is required to rotate ciphers", + ); + }); + }); }); function setEncryptionKeyFlag(value: boolean) { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index ff99c8b1d8..1f6581b333 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -56,6 +56,7 @@ import { CipherCollectionsRequest } from "../models/request/cipher-collections.r import { CipherCreateRequest } from "../models/request/cipher-create.request"; import { CipherPartialRequest } from "../models/request/cipher-partial.request"; import { CipherShareRequest } from "../models/request/cipher-share.request"; +import { CipherWithIdRequest } from "../models/request/cipher-with-id.request"; import { CipherRequest } from "../models/request/cipher.request"; import { CipherResponse } from "../models/response/cipher.response"; import { AttachmentView } from "../models/view/attachment.view"; @@ -1168,6 +1169,34 @@ export class CipherService implements CipherServiceAbstraction { }); } + async getRotatedData( + originalUserKey: UserKey, + newUserKey: UserKey, + userId: UserId, + ): Promise { + if (originalUserKey == null) { + throw new Error("Original user key is required to rotate ciphers"); + } + if (newUserKey == null) { + throw new Error("New user key is required to rotate ciphers"); + } + + let encryptedCiphers: CipherWithIdRequest[] = []; + + const ciphers = await this.getAllDecrypted(); + if (!ciphers || ciphers.length === 0) { + return encryptedCiphers; + } + encryptedCiphers = await Promise.all( + ciphers.map(async (cipher) => { + const encryptedCipher = await this.encrypt(cipher, newUserKey, originalUserKey); + return new CipherWithIdRequest(encryptedCipher); + }), + ); + + return encryptedCiphers; + } + // Helpers // In the case of a cipher that is being shared with an organization, we want to decrypt the diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index 8c3be9abe8..6f181cf882 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -178,6 +178,29 @@ describe("Folder Service", () => { // }); }); + describe("getRotatedData", () => { + const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + let encryptedKey: EncString; + + beforeEach(() => { + encryptedKey = new EncString("Re-encrypted Folder"); + cryptoService.encrypt.mockResolvedValue(encryptedKey); + }); + + it("returns re-encrypted user folders", async () => { + const result = await folderService.getRotatedData(originalUserKey, newUserKey, mockUserId); + + expect(result[0]).toMatchObject({ id: "1", name: "Re-encrypted Folder" }); + }); + + it("throws if the new user key is null", async () => { + await expect(folderService.getRotatedData(originalUserKey, null, mockUserId)).rejects.toThrow( + "New user key is required for rotation.", + ); + }); + }); + function folderData(id: string, name: string) { const data = new FolderData({} as any); data.id = id; diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index 584567aee8..b3cfeb3c16 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -6,12 +6,14 @@ import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { UserKey } from "../../../types/key"; import { CipherService } from "../../../vault/abstractions/cipher.service"; import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction"; import { FolderData } from "../../../vault/models/data/folder.data"; import { Folder } from "../../../vault/models/domain/folder"; import { FolderView } from "../../../vault/models/view/folder.view"; import { Cipher } from "../../models/domain/cipher"; +import { FolderWithIdRequest } from "../../models/request/folder-with-id.request"; import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state"; export class FolderService implements InternalFolderServiceAbstraction { @@ -170,4 +172,27 @@ export class FolderService implements InternalFolderServiceAbstraction { decryptedFolders.push(noneFolder); return decryptedFolders; } + + async getRotatedData( + originalUserKey: UserKey, + newUserKey: UserKey, + userId: UserId, + ): Promise { + if (newUserKey == null) { + throw new Error("New user key is required for rotation."); + } + + let encryptedFolders: FolderWithIdRequest[] = []; + const folders = await firstValueFrom(this.folderViews$); + if (!folders) { + return encryptedFolders; + } + encryptedFolders = await Promise.all( + folders.map(async (folder) => { + const encryptedFolder = await this.encrypt(folder, newUserKey); + return new FolderWithIdRequest(encryptedFolder); + }), + ); + return encryptedFolders; + } }